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/dist/index.mjs CHANGED
@@ -97,7 +97,11 @@ function unsupportedSync(runtime) {
97
97
  }
98
98
  async function readCursor(cursorCol, target) {
99
99
  const doc = await cursorCol.findOne({ target });
100
- return { pushMs: doc?.pushMs ?? 0, pullMs: doc?.pullMs ?? 0 };
100
+ return {
101
+ pushMs: doc?.pushMs ?? 0,
102
+ pullMs: doc?.pullMs ?? 0,
103
+ pullCursor: doc?.pullCursor
104
+ };
101
105
  }
102
106
  async function writeCursor(cursorCol, target, cursor) {
103
107
  const updated = await cursorCol.updateOne({ target }, { $set: { ...cursor } });
@@ -105,13 +109,19 @@ async function writeCursor(cursorCol, target, cursor) {
105
109
  await cursorCol.insert({ target, ...cursor });
106
110
  }
107
111
  }
112
+ function isCursorAdapter(adapter) {
113
+ return typeof adapter.pullWithCursor === "function";
114
+ }
115
+ var MAX_PULL_PAGES = 1e4;
108
116
  async function runSync(handle, adapter, options, syncSchemas = {}) {
109
117
  const direction = options.direction ?? "both";
110
118
  const target = options.target ?? "default";
111
119
  const doPush = direction === "push" || direction === "both";
112
120
  const doPull = direction === "pull" || direction === "both";
113
- if (doPull && !adapter.pull) {
114
- throw new Error(`sync direction '${direction}' requires adapter.pull()`);
121
+ if (doPull && !adapter.pull && !isCursorAdapter(adapter)) {
122
+ throw new Error(
123
+ `sync direction '${direction}' requires adapter.pull() or adapter.pullWithCursor()`
124
+ );
115
125
  }
116
126
  if (doPush && !adapter.push) {
117
127
  throw new Error(`sync direction '${direction}' requires adapter.push()`);
@@ -128,17 +138,35 @@ async function runSync(handle, adapter, options, syncSchemas = {}) {
128
138
  let pulled = 0;
129
139
  let skipped = 0;
130
140
  let quarantined = 0;
141
+ let pullCursor = cursor.pullCursor;
142
+ async function importOne(changeset) {
143
+ if (!changeset || changeset === "[]") return;
144
+ if (useValidated) {
145
+ const report = await handle.importChangesValidated(changeset, JSON.stringify(scopedSchemas));
146
+ pulled += report.applied;
147
+ skipped += report.skipped;
148
+ quarantined += report.quarantined;
149
+ } else {
150
+ pulled += await handle.importChanges(changeset);
151
+ }
152
+ }
131
153
  if (doPull) {
132
- const remote = await adapter.pull(0);
133
- if (remote && remote !== "[]") {
134
- if (useValidated) {
135
- const report = await handle.importChangesValidated(remote, JSON.stringify(scopedSchemas));
136
- pulled = report.applied;
137
- skipped = report.skipped;
138
- quarantined = report.quarantined;
139
- } else {
140
- pulled = await handle.importChanges(remote);
154
+ if (isCursorAdapter(adapter)) {
155
+ let pages = 0;
156
+ for (; ; ) {
157
+ const result = await adapter.pullWithCursor(pullCursor ?? null);
158
+ await importOne(result.changeset);
159
+ pullCursor = result.cursor;
160
+ await writeCursor(cursorCol, target, { ...cursor, pullCursor });
161
+ if (!result.hasMore) break;
162
+ if (++pages >= MAX_PULL_PAGES) {
163
+ throw new Error(
164
+ `sync: origin returned hasMore after ${MAX_PULL_PAGES} pages for target '${target}' \u2014 it is probably not advancing its cursor.`
165
+ );
166
+ }
141
167
  }
168
+ } else {
169
+ await importOne(await adapter.pull(0));
142
170
  }
143
171
  }
144
172
  let pushed = 0;
@@ -148,7 +176,8 @@ async function runSync(handle, adapter, options, syncSchemas = {}) {
148
176
  }
149
177
  await writeCursor(cursorCol, target, {
150
178
  pushMs: cursor.pushMs,
151
- pullMs: cursor.pullMs
179
+ pullMs: cursor.pullMs,
180
+ ...pullCursor !== void 0 ? { pullCursor } : {}
152
181
  });
153
182
  return { pushed, pulled, skipped, quarantined, cursor: 0 };
154
183
  }
@@ -189,6 +218,478 @@ var HttpSyncAdapter = class {
189
218
  }
190
219
  };
191
220
 
221
+ // src/derive-id.ts
222
+ var FNV1A128_OFFSET_BASIS = 0x6c62272e07bb014262b821756295c58dn;
223
+ var FNV1A128_PRIME = 0x0000000001000000000000000000013bn;
224
+ var MASK_128 = (1n << 128n) - 1n;
225
+ var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
226
+ var UTF8 = new TextEncoder();
227
+ function encodeUlid(value) {
228
+ let out = "";
229
+ for (let i = 25; i >= 0; i--) {
230
+ out += CROCKFORD[Number(value >> BigInt(i * 5) & 31n)];
231
+ }
232
+ return out;
233
+ }
234
+ function deriveDocId(collection, key) {
235
+ const bytes = [...UTF8.encode(collection), 0, ...UTF8.encode(key)];
236
+ let hash = FNV1A128_OFFSET_BASIS;
237
+ for (const byte of bytes) {
238
+ hash ^= BigInt(byte);
239
+ hash = hash * FNV1A128_PRIME & MASK_128;
240
+ }
241
+ return encodeUlid(hash);
242
+ }
243
+
244
+ // src/replication/coverage.ts
245
+ var COVERAGE_COLLECTION = "__taladb_replica";
246
+ function coverageKey(key) {
247
+ return [
248
+ key.origin,
249
+ key.collection,
250
+ key.scope,
251
+ `p${key.projectionVersion}`,
252
+ `s${key.schemaVersion}`
253
+ ].map(encodeURIComponent).join("|");
254
+ }
255
+ var CoverageStore = class {
256
+ constructor(db) {
257
+ this.col = db.collection(COVERAGE_COLLECTION);
258
+ }
259
+ async read(key) {
260
+ const doc = await this.col.findOne({ key: coverageKey(key) });
261
+ if (!doc?.state) return { status: "empty" };
262
+ try {
263
+ return JSON.parse(doc.state);
264
+ } catch {
265
+ return { status: "empty" };
266
+ }
267
+ }
268
+ async write(key, state) {
269
+ const k = coverageKey(key);
270
+ const state_json = JSON.stringify(state);
271
+ await this.col.replaceManyWithIds(
272
+ [{ _id: deriveDocId(COVERAGE_COLLECTION, k), key: k, state: state_json }],
273
+ "local"
274
+ );
275
+ }
276
+ /** Drop a scope's coverage, forcing a fresh bootstrap on next use. */
277
+ async clear(key) {
278
+ const k = coverageKey(key);
279
+ await this.col.deleteManyWithIds([deriveDocId(COVERAGE_COLLECTION, k)], "local");
280
+ }
281
+ };
282
+ function isAuthoritative(state) {
283
+ return state.status === "complete";
284
+ }
285
+ function rowsApplied(state) {
286
+ switch (state.status) {
287
+ case "hydrating":
288
+ case "complete":
289
+ case "best-effort":
290
+ return state.rowsApplied;
291
+ default:
292
+ return 0;
293
+ }
294
+ }
295
+ function progress(state) {
296
+ if (state.status === "complete") return 1;
297
+ if (state.status !== "hydrating" || !state.total) return void 0;
298
+ return Math.min(1, state.rowsApplied / state.total);
299
+ }
300
+
301
+ // src/replication/coordinator.ts
302
+ var DEFAULT_PAGE_SIZE = 500;
303
+ var defaultYield = () => new Promise((resolve) => setTimeout(resolve, 0));
304
+ var MAX_BOOTSTRAP_PAGES = 1e5;
305
+ var inflightByDatabase = /* @__PURE__ */ new WeakMap();
306
+ var REPLICA_SCOPE_FIELD = "_replica_scope";
307
+ var REPLICA_REVISION_FIELD = "_remote_rev";
308
+ var ReplicationCoordinator = class {
309
+ constructor(db, source, options = {}) {
310
+ this.db = db;
311
+ this.source = source;
312
+ this.coverage = new CoverageStore(db);
313
+ this.key = {
314
+ origin: source.origin,
315
+ collection: source.collection,
316
+ scope: source.scope,
317
+ projectionVersion: source.projectionVersion,
318
+ schemaVersion: source.schemaVersion
319
+ };
320
+ this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
321
+ this.yieldFn = options.yieldFn ?? defaultYield;
322
+ this.onProgress = options.onProgress;
323
+ this.collectionOptions = options.collectionOptions;
324
+ let shared = inflightByDatabase.get(db);
325
+ if (!shared) {
326
+ shared = /* @__PURE__ */ new Map();
327
+ inflightByDatabase.set(db, shared);
328
+ }
329
+ this.inflight = shared;
330
+ }
331
+ get replicaScope() {
332
+ return coverageKey(this.key);
333
+ }
334
+ get identityNamespace() {
335
+ return `${this.source.origin}\0${this.source.scope}\0${this.source.collection}`;
336
+ }
337
+ getCoverage() {
338
+ return this.coverage.read(this.key);
339
+ }
340
+ /** Whether a purely local read is authorized right now. */
341
+ async isReady() {
342
+ return isAuthoritative(await this.getCoverage());
343
+ }
344
+ /** Dedup by intent: identical concurrent work joins rather than duplicating. */
345
+ dedup(key, run) {
346
+ const existing = this.inflight.get(key);
347
+ if (existing) return existing;
348
+ const pass = run().finally(() => this.inflight.delete(key));
349
+ this.inflight.set(key, pass);
350
+ return pass;
351
+ }
352
+ /**
353
+ * Write a batch of remote rows into the local collection.
354
+ *
355
+ * One commit for the whole batch, ids derived from the origin's primary key, and
356
+ * `origin: 'remote'` so the rows can never replicate back out at the origin they
357
+ * came from. This is the *only* write path in the coordinator — bootstrap, delta
358
+ * and bridge all funnel through it, which is precisely why they converge instead
359
+ * of conflicting.
360
+ */
361
+ async applyRows(rows) {
362
+ if (rows.length === 0) return [];
363
+ const col = this.db.collection(this.source.collection, this.collectionOptions);
364
+ const docs = rows.map(
365
+ (row) => {
366
+ const revision = this.source.revisionOf(row);
367
+ return {
368
+ ...this.source.mapRow(row),
369
+ _id: deriveDocId(this.identityNamespace, String(this.source.keyOf(row))),
370
+ [REPLICA_SCOPE_FIELD]: this.replicaScope,
371
+ [REPLICA_REVISION_FIELD]: revision
372
+ };
373
+ }
374
+ );
375
+ await col.replaceManyWithIds(docs, "remote");
376
+ return docs.map((doc) => doc._id);
377
+ }
378
+ /**
379
+ * Hydrate the scope: walk the origin page by page until the whole collection is
380
+ * local, then mark it complete.
381
+ *
382
+ * Resumable and idempotent. If the walk is interrupted — a reload, a crash, a
383
+ * dead network — the next call picks up from the last committed page, and
384
+ * re-applying a page it already wrote is a no-op because the ids are derived.
385
+ */
386
+ hydrate() {
387
+ return this.dedup(`${this.replicaScope}:hydrate`, () => this.runHydrate());
388
+ }
389
+ async runHydrate() {
390
+ let state = await this.coverage.read(this.key);
391
+ if (state.status === "complete") return state;
392
+ let page = null;
393
+ let snapshot = null;
394
+ let rowsApplied2 = 0;
395
+ let total;
396
+ let deltaCursor;
397
+ if (state.status === "hydrating") {
398
+ page = state.nextPage;
399
+ snapshot = state.snapshot;
400
+ rowsApplied2 = state.rowsApplied;
401
+ total = state.total;
402
+ deltaCursor = state.deltaCursor;
403
+ } else if (state.status === "error" && state.snapshot) {
404
+ page = state.resumeFrom;
405
+ snapshot = state.snapshot;
406
+ rowsApplied2 = state.rowsApplied ?? 0;
407
+ total = state.total;
408
+ deltaCursor = state.deltaCursor;
409
+ }
410
+ let snapshotSupported = true;
411
+ let pages = 0;
412
+ try {
413
+ for (; ; ) {
414
+ const result = await this.source.bootstrap({ page, snapshot, limit: this.pageSize });
415
+ if (snapshot !== null && result.snapshot !== void 0 && result.snapshot !== snapshot) {
416
+ throw new Error(
417
+ `replication: origin '${this.source.origin}' changed snapshot token mid-walk`
418
+ );
419
+ }
420
+ if (snapshot === null && result.snapshot) snapshot = result.snapshot;
421
+ if (result.snapshot === void 0 && page === null) snapshotSupported = false;
422
+ if (result.deltaCursor && !deltaCursor) deltaCursor = result.deltaCursor;
423
+ if (result.total !== void 0) total = result.total;
424
+ rowsApplied2 += (await this.applyRows(result.rows)).length;
425
+ page = result.nextPage;
426
+ if (page !== null) {
427
+ const next = {
428
+ status: "hydrating",
429
+ snapshot: snapshot ?? "",
430
+ nextPage: page,
431
+ rowsApplied: rowsApplied2,
432
+ ...deltaCursor !== void 0 ? { deltaCursor } : {},
433
+ ...total !== void 0 ? { total } : {}
434
+ };
435
+ await this.coverage.write(this.key, next);
436
+ this.onProgress?.(next);
437
+ if (++pages >= MAX_BOOTSTRAP_PAGES) {
438
+ throw new Error(
439
+ `replication: origin '${this.source.origin}' offered more than ${MAX_BOOTSTRAP_PAGES} bootstrap pages for '${this.source.collection}' \u2014 it is probably not advancing nextPage.`
440
+ );
441
+ }
442
+ await this.yieldFn();
443
+ continue;
444
+ }
445
+ if (snapshotSupported && this.source.delta && deltaCursor === void 0) {
446
+ throw new Error(
447
+ `replication: origin '${this.source.origin}' supports delta refresh but did not issue deltaCursor on the first bootstrap page`
448
+ );
449
+ }
450
+ state = snapshotSupported ? {
451
+ status: "complete",
452
+ cursor: deltaCursor ?? "",
453
+ completedAt: Date.now(),
454
+ rowsApplied: rowsApplied2,
455
+ ...total !== void 0 ? { total } : {}
456
+ } : {
457
+ // Every row the origin offered was applied — but without a snapshot
458
+ // we cannot prove we saw a consistent view of it, so we must not
459
+ // claim completeness. Reads keep going to the network.
460
+ status: "best-effort",
461
+ cursor: deltaCursor ?? "",
462
+ reason: "the origin did not return a snapshot token, so a row that moved between pages during the walk may have been missed",
463
+ rowsApplied: rowsApplied2,
464
+ ...total !== void 0 ? { total } : {}
465
+ };
466
+ await this.coverage.write(this.key, state);
467
+ this.onProgress?.(state);
468
+ return state;
469
+ }
470
+ } catch (error) {
471
+ const failed = {
472
+ status: "error",
473
+ resumeFrom: page ?? 0,
474
+ ...snapshot ? { snapshot } : {},
475
+ ...deltaCursor !== void 0 ? { deltaCursor } : {},
476
+ rowsApplied: rowsApplied2,
477
+ ...total !== void 0 ? { total } : {},
478
+ error: error instanceof Error ? error.message : String(error)
479
+ };
480
+ await this.coverage.write(this.key, failed);
481
+ this.onProgress?.(failed);
482
+ throw error;
483
+ }
484
+ }
485
+ /**
486
+ * Apply incremental changes since the stored cursor.
487
+ *
488
+ * Deletions are applied by mapping the origin's primary keys through the same
489
+ * `deriveDocId`, and are written with `origin: 'remote'` so they leave no
490
+ * tombstone — the origin already knows it deleted these, and a tombstone would
491
+ * push its own deletion back at it.
492
+ */
493
+ refresh() {
494
+ return this.dedup(`${this.replicaScope}:refresh`, () => this.runRefresh());
495
+ }
496
+ async runRefresh() {
497
+ const state = await this.coverage.read(this.key);
498
+ if (state.status !== "complete") return state;
499
+ if (!this.source.delta) return state;
500
+ const col = this.db.collection(this.source.collection);
501
+ let cursor = state.cursor;
502
+ let rowsApplied2 = state.rowsApplied;
503
+ for (; ; ) {
504
+ const page = await this.source.delta(cursor);
505
+ rowsApplied2 += (await this.applyRows(page.changed)).length;
506
+ if (page.deleted.length > 0) {
507
+ const ids = page.deleted.map(
508
+ (k) => deriveDocId(this.identityNamespace, String(k))
509
+ );
510
+ await col.deleteManyWithIds(ids, "remote");
511
+ }
512
+ cursor = page.cursor;
513
+ const next = { ...state, cursor, rowsApplied: rowsApplied2 };
514
+ await this.coverage.write(this.key, next);
515
+ if (!page.hasMore) {
516
+ this.onProgress?.(next);
517
+ return next;
518
+ }
519
+ await this.yieldFn();
520
+ }
521
+ }
522
+ /**
523
+ * Cold-start bridge: fetch exactly the rows one query needs, right now.
524
+ *
525
+ * Needed because a SPA or React Native app has no server render to paint behind
526
+ * while the replica fills. The rows land in the same collection under the same
527
+ * derived ids as the walk's, so this is not a cache — it is the replica, arriving
528
+ * early.
529
+ *
530
+ * **Does not advance coverage.** These rows did not come from the bootstrap
531
+ * snapshot and prove nothing about completeness; treating them as progress would
532
+ * let a page-1 fetch masquerade as a hydrated catalog.
533
+ */
534
+ bridge(query) {
535
+ if (!this.source.fetchQuery) return Promise.resolve({ count: 0, ids: [] });
536
+ const key = `bridge:${coverageKey(this.key)}:${JSON.stringify(query)}`;
537
+ return this.dedup(key, async () => {
538
+ const rows = await this.source.fetchQuery(query);
539
+ const ids = await this.applyRows(rows);
540
+ return { count: ids.length, ids };
541
+ });
542
+ }
543
+ /** Drop coverage and force a fresh bootstrap. Local rows are left alone. */
544
+ async reset() {
545
+ await this.coverage.clear(this.key);
546
+ }
547
+ };
548
+
549
+ // src/replication/rest.ts
550
+ function parseRows(body, endpoint) {
551
+ if (Array.isArray(body)) return body;
552
+ if (body && typeof body === "object") {
553
+ const env = body;
554
+ for (const field of ["data", "items", "rows"]) {
555
+ const value = env[field];
556
+ if (Array.isArray(value)) return value;
557
+ }
558
+ throw new Error(
559
+ `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.`
560
+ );
561
+ }
562
+ throw new Error(
563
+ `taladb: expected an array or object from ${endpoint}, got ${typeof body}.`
564
+ );
565
+ }
566
+ function pick(body, names) {
567
+ if (!body || typeof body !== "object") return void 0;
568
+ const rec = body;
569
+ for (const n of names) {
570
+ if (rec[n] !== void 0) return rec[n];
571
+ const meta = rec.meta;
572
+ if (meta && meta[n] !== void 0) return meta[n];
573
+ }
574
+ return void 0;
575
+ }
576
+ function createRestSource(options) {
577
+ const {
578
+ endpoint,
579
+ collection,
580
+ origin = endpoint,
581
+ scope = "global",
582
+ projectionVersion = 1,
583
+ schemaVersion = 1,
584
+ key = "id",
585
+ revision = "rev",
586
+ mapRow,
587
+ getAuth,
588
+ paths,
589
+ toParams,
590
+ parse,
591
+ pagination = "page"
592
+ } = options;
593
+ const doFetch = options.fetch ?? globalThis.fetch;
594
+ async function get(path, params) {
595
+ const url = new URL(path, globalThis.location?.origin ?? "http://localhost");
596
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
597
+ const headers = getAuth ? await getAuth() : void 0;
598
+ const response = await doFetch(url.href, { headers });
599
+ if (!response.ok) {
600
+ throw new Error(
601
+ `taladb: ${path} responded ${response.status} ${response.statusText}`
602
+ );
603
+ }
604
+ return response.json();
605
+ }
606
+ const rowsFrom = (body) => parse ? parse(body) : parseRows(body, endpoint);
607
+ return {
608
+ origin,
609
+ collection,
610
+ scope,
611
+ projectionVersion,
612
+ schemaVersion,
613
+ keyOf: (row) => {
614
+ const value = row[key];
615
+ if (value === void 0 || value === null) {
616
+ throw new Error(
617
+ `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.`
618
+ );
619
+ }
620
+ return String(value);
621
+ },
622
+ revisionOf: (row) => {
623
+ const value = typeof revision === "function" ? revision(row) : row[revision];
624
+ if (value === void 0 || value === null) {
625
+ throw new Error(
626
+ `taladb: row from ${endpoint} has no authoritative revision. Pass { revision } to name the monotonic revision field.`
627
+ );
628
+ }
629
+ const n = Number(value);
630
+ if (!Number.isSafeInteger(n)) {
631
+ throw new Error(`taladb: authoritative revision must be a safe integer, got ${String(value)}`);
632
+ }
633
+ return n;
634
+ },
635
+ mapRow: (row) => {
636
+ if (mapRow) return mapRow(row);
637
+ const { _id, ...rest } = row;
638
+ void _id;
639
+ return rest;
640
+ },
641
+ bootstrap: async (request) => {
642
+ const params = { limit: String(request.limit) };
643
+ if (request.page !== null) params.page = String(request.page);
644
+ if (request.snapshot !== null) params.snapshot = request.snapshot;
645
+ const body = await get(endpoint + (paths?.bootstrap ?? ""), params);
646
+ const rows = rowsFrom(body);
647
+ const nextPage = pick(body, ["nextPage", "next_page", "next"]);
648
+ const snapshot = pick(body, ["snapshot"]);
649
+ const deltaCursor = pick(body, ["deltaCursor", "delta_cursor", "cursor"]);
650
+ const total = pick(body, ["total", "totalCount", "count"]);
651
+ return {
652
+ rows,
653
+ // An origin that reports no explicit `nextPage` is treated as exhausted
654
+ // once it returns a short page — the conventional REST behavior.
655
+ nextPage: nextPage !== void 0 ? nextPage : rows.length < request.limit ? null : pagination === "offset" ? Number(request.page ?? 0) + request.limit : Number(request.page ?? 1) + 1,
656
+ ...snapshot !== void 0 ? { snapshot } : {},
657
+ ...deltaCursor !== void 0 ? { deltaCursor } : {},
658
+ ...total !== void 0 ? { total } : {}
659
+ };
660
+ },
661
+ ...options.delta === true || paths?.delta ? { delta: async (cursor) => {
662
+ const body = await get(endpoint + (paths?.delta ?? ""), { since: cursor });
663
+ const changed = rowsFrom(body);
664
+ const deleted = pick(body, ["deleted", "removed"]) ?? [];
665
+ const next = pick(body, ["cursor", "now", "nextCursor"]);
666
+ return {
667
+ changed,
668
+ deleted: deleted.map(String),
669
+ cursor: next ?? cursor,
670
+ hasMore: Boolean(pick(body, ["hasMore", "has_more"]))
671
+ };
672
+ } } : {},
673
+ fetchQuery: async (query) => {
674
+ if (!toParams && Object.values(query.filter ?? {}).some((v) => typeof v === "object" && v !== null)) {
675
+ throw new Error(
676
+ "taladb: bridge filters with operators require RestSourceOptions.toParams; the default translator only supports scalar equality fields."
677
+ );
678
+ }
679
+ const sortEntry = Object.entries(query.sort ?? {})[0];
680
+ const params = toParams ? toParams(query) : {
681
+ ...query.page !== void 0 ? { page: String(query.page) } : {},
682
+ ...query.limit !== void 0 ? { limit: String(query.limit) } : {},
683
+ ...Object.fromEntries(
684
+ Object.entries(query.filter ?? {}).map(([k, v]) => [k, String(v)])
685
+ ),
686
+ ...sortEntry ? { sort: sortEntry[0], order: sortEntry[1] === -1 ? "desc" : "asc" } : {}
687
+ };
688
+ return rowsFrom(await get(endpoint, params));
689
+ }
690
+ };
691
+ }
692
+
192
693
  // src/index.ts
193
694
  var TalaDbValidationError = class extends Error {
194
695
  constructor(cause, context) {
@@ -215,18 +716,52 @@ function deepEqual(a, b) {
215
716
  );
216
717
  }
217
718
  function applySchema(col, options) {
218
- const { schema, validateOnRead = false, migrateDocument, syncSchema, persistMigrations = false } = options;
719
+ const {
720
+ schema,
721
+ validateOnRead = false,
722
+ migrateDocument,
723
+ downgradeDocument,
724
+ syncSchema,
725
+ persistMigrations = false,
726
+ allowFieldRemoval = false,
727
+ retiredFields = []
728
+ } = options;
219
729
  const targetVersion = syncSchema?.version ?? 0;
220
730
  if (migrateDocument && targetVersion < 1) {
221
731
  throw new Error("CollectionOptions.migrateDocument requires syncSchema.version (the migration target)");
222
732
  }
733
+ if (downgradeDocument && targetVersion < 1) {
734
+ throw new Error("CollectionOptions.downgradeDocument requires syncSchema.version (the shape this build reads)");
735
+ }
223
736
  if (syncSchema && targetVersion < 1 && (syncSchema.renames || syncSchema.defaults)) {
224
737
  throw new Error(
225
738
  "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"
226
739
  );
227
740
  }
228
741
  const stampVersion = targetVersion > 0;
229
- if (!schema && !migrateDocument && !stampVersion) return col;
742
+ if (!schema && !migrateDocument && !downgradeDocument && !stampVersion) return col;
743
+ const retired = new Set(retiredFields);
744
+ const engineOwned = /* @__PURE__ */ new Set([
745
+ "_id",
746
+ "_v",
747
+ "_changed_at",
748
+ "_remote",
749
+ "_remote_rev",
750
+ "_replica_scope"
751
+ ]);
752
+ const downcastViews = /* @__PURE__ */ new WeakSet();
753
+ function preserveFields(original, next, preserveUnknown, preserveVersion = true) {
754
+ let out = null;
755
+ for (const k of Object.keys(original)) {
756
+ const ownedField = engineOwned.has(k) && (k !== "_v" || preserveVersion);
757
+ const mustRestore = ownedField || preserveUnknown && !retired.has(k) && !(k in next);
758
+ if (!mustRestore) continue;
759
+ if (!ownedField && k in next) continue;
760
+ out ?? (out = { ...next });
761
+ out[k] = original[k];
762
+ }
763
+ return out ?? next;
764
+ }
230
765
  function parseWrite(doc, label) {
231
766
  try {
232
767
  return schema.parse(doc);
@@ -234,10 +769,51 @@ function applySchema(col, options) {
234
769
  throw new TalaDbValidationError(err, label);
235
770
  }
236
771
  }
772
+ function assertWritableDocument(doc, label) {
773
+ if (doc && typeof doc === "object" && downcastViews.has(doc)) {
774
+ throw new Error(`${label}: a downgradeDocument result is a read-only compatibility view`);
775
+ }
776
+ const version = doc?._v;
777
+ if (targetVersion > 0 && typeof version === "number" && version > targetVersion) {
778
+ throw new Error(
779
+ `${label}: this client supports schema v${targetVersion}, but the document is v${version}`
780
+ );
781
+ }
782
+ }
783
+ function writableFilter(filter) {
784
+ if (targetVersion < 1) return filter;
785
+ return {
786
+ $and: [
787
+ filter,
788
+ {
789
+ $or: [
790
+ { _v: { $exists: false } },
791
+ { _v: { $lte: targetVersion } }
792
+ ]
793
+ }
794
+ ]
795
+ };
796
+ }
797
+ function assertSafeUpdate(update) {
798
+ const record = update;
799
+ for (const op of ["$set", "$unset", "$inc", "$push", "$pull"]) {
800
+ const fields = record[op];
801
+ if (!fields) continue;
802
+ for (const field of Object.keys(fields)) {
803
+ if (engineOwned.has(field)) {
804
+ throw new Error(`update cannot modify engine-owned field '${field}'`);
805
+ }
806
+ }
807
+ }
808
+ }
237
809
  function stamp(doc) {
238
810
  if (!stampVersion || doc._v !== void 0) return doc;
239
811
  return { ...doc, _v: targetVersion };
240
812
  }
813
+ function stampDoc(doc) {
814
+ if (!stampVersion || doc._v !== void 0) return doc;
815
+ return { ...doc, _v: targetVersion };
816
+ }
241
817
  function diffUpdate(original, migrated) {
242
818
  const $set = {};
243
819
  const $unset = {};
@@ -245,85 +821,179 @@ function applySchema(col, options) {
245
821
  if (k === "_id") continue;
246
822
  if (!deepEqual(migrated[k], original[k])) $set[k] = migrated[k];
247
823
  }
248
- for (const k of Object.keys(original)) {
249
- if (k !== "_id" && !(k in migrated)) $unset[k] = true;
824
+ if (allowFieldRemoval || retired.size > 0) {
825
+ for (const k of Object.keys(original)) {
826
+ if (k !== "_id" && !(k in migrated) && (allowFieldRemoval || retired.has(k))) $unset[k] = true;
827
+ }
250
828
  }
251
829
  const update = {};
252
830
  if (Object.keys($set).length) update.$set = $set;
253
831
  if (Object.keys($unset).length) update.$unset = $unset;
254
832
  return Object.keys(update).length ? update : null;
255
833
  }
256
- function migrateRead(doc) {
257
- if (!migrateDocument) return doc;
834
+ function normalizeRead(doc) {
258
835
  const fromVersion = typeof doc._v === "number" ? doc._v : 0;
259
- if (fromVersion >= targetVersion) return doc;
260
- return { ...migrateDocument(doc, fromVersion), _v: targetVersion };
836
+ if (migrateDocument && fromVersion < targetVersion) {
837
+ const up = { ...migrateDocument(doc, fromVersion), _v: targetVersion };
838
+ return {
839
+ // The migration owns the version transition, while every other
840
+ // engine-owned field continues to come from the stored document.
841
+ value: allowFieldRemoval ? preserveFields(doc, up, false, false) : preserveFields(doc, up, true, false),
842
+ persistable: true
843
+ };
844
+ }
845
+ if (downgradeDocument && fromVersion > targetVersion) {
846
+ const projected = {
847
+ ...downgradeDocument(doc, fromVersion),
848
+ ...doc._id !== void 0 ? { _id: doc._id } : {},
849
+ _v: fromVersion
850
+ };
851
+ downcastViews.add(projected);
852
+ return { value: projected, persistable: false };
853
+ }
854
+ return { value: doc, persistable: true };
261
855
  }
262
856
  function validateRead(doc) {
263
857
  if (!validateOnRead || !schema) return doc;
264
858
  try {
265
- return schema.parse(doc);
859
+ const parsed = schema.parse(doc);
860
+ const fromVersion = typeof doc._v === "number" ? doc._v : 0;
861
+ return preserveFields(doc, parsed, targetVersion > 0 && fromVersion > targetVersion);
266
862
  } catch (err) {
267
863
  throw new TalaDbValidationError(err, "read");
268
864
  }
269
865
  }
270
- async function persistAll(originals, migrated) {
866
+ async function persistAll(originals, normalized) {
271
867
  if (!persistMigrations) return;
272
868
  for (let i = 0; i < originals.length; i++) {
273
869
  const original = originals[i];
274
- if (migrated[i] === original || typeof original._id !== "string") continue;
275
- const update = diffUpdate(original, migrated[i]);
870
+ if (!normalized[i].persistable) continue;
871
+ const migrated = normalized[i].value;
872
+ if (migrated === original || typeof original._id !== "string") continue;
873
+ const update = diffUpdate(original, migrated);
276
874
  if (!update) continue;
277
875
  try {
278
- await col.updateOne({ _id: original._id }, update);
876
+ const guards = [{ _id: original._id }];
877
+ if (original._v === void 0) guards.push({ _v: { $exists: false } });
878
+ else guards.push({ _v: original._v });
879
+ if (original._changed_at !== void 0) {
880
+ guards.push({ _changed_at: original._changed_at });
881
+ }
882
+ await col.updateOne({ $and: guards }, update);
279
883
  } catch {
280
884
  }
281
885
  }
282
886
  }
283
- const wrapReads = Boolean(migrateDocument) || validateOnRead && Boolean(schema);
887
+ const wrapReads = Boolean(migrateDocument) || Boolean(downgradeDocument) || validateOnRead && Boolean(schema);
284
888
  const wrapWrites = Boolean(schema) || stampVersion;
889
+ function pipelinePreservesDocuments(pipeline) {
890
+ return pipeline.every((stage) => !("$group" in stage) && !("$project" in stage));
891
+ }
892
+ function normalizeViewRows(docs) {
893
+ return docs.map((doc) => validateRead(normalizeRead(doc).value));
894
+ }
285
895
  return {
286
896
  ...col,
287
897
  insert: wrapWrites ? async (doc) => {
898
+ assertWritableDocument(doc, "insert");
288
899
  if (schema) parseWrite(doc, "insert");
289
900
  return col.insert(stamp(doc));
290
901
  } : col.insert.bind(col),
291
902
  insertMany: wrapWrites ? async (docs) => {
903
+ docs.forEach((doc, i) => assertWritableDocument(doc, `insertMany[${i}]`));
292
904
  if (schema) docs.forEach((doc, i) => parseWrite(doc, `insertMany[${i}]`));
293
905
  return col.insertMany(docs.map(stamp));
294
906
  } : col.insertMany.bind(col),
907
+ // Rows arriving from a remote origin are validated like any other write. This
908
+ // is the "parse, don't assert" boundary: the compile-time generic and the
909
+ // runtime schema check have to be the same seam, or a malformed server
910
+ // response walks straight into a typed collection.
911
+ replaceManyWithIds: wrapWrites ? async (docs, origin) => {
912
+ if (origin !== "remote") {
913
+ docs.forEach((doc, i) => assertWritableDocument(doc, `replaceManyWithIds[${i}]`));
914
+ if (targetVersion > 0) {
915
+ throw new Error(
916
+ "local replaceManyWithIds is disabled on versioned collections; use updateOne/updateMany so schema-version guards are atomic"
917
+ );
918
+ }
919
+ }
920
+ if (schema) docs.forEach((doc, i) => {
921
+ const { _replica_scope, _remote_rev, ...schemaDoc } = doc;
922
+ void _replica_scope;
923
+ void _remote_rev;
924
+ parseWrite(schemaDoc, `replaceManyWithIds[${i}]`);
925
+ });
926
+ return col.replaceManyWithIds(docs.map((d) => stampDoc(d)), origin);
927
+ } : col.replaceManyWithIds.bind(col),
928
+ deleteManyWithIds: stampVersion ? async (ids, origin) => {
929
+ if (origin !== "remote") {
930
+ throw new Error(
931
+ "local deleteManyWithIds is disabled on versioned collections; use deleteOne/deleteMany so schema-version guards are atomic"
932
+ );
933
+ }
934
+ return col.deleteManyWithIds(ids, origin);
935
+ } : col.deleteManyWithIds.bind(col),
936
+ updateOne: wrapWrites ? async (filter, update) => {
937
+ assertSafeUpdate(update);
938
+ return col.updateOne(writableFilter(filter), update);
939
+ } : col.updateOne.bind(col),
940
+ updateMany: wrapWrites ? async (filter, update) => {
941
+ assertSafeUpdate(update);
942
+ return col.updateMany(writableFilter(filter), update);
943
+ } : col.updateMany.bind(col),
944
+ deleteOne: stampVersion ? (filter) => col.deleteOne(writableFilter(filter)) : col.deleteOne.bind(col),
945
+ deleteMany: stampVersion ? (filter) => col.deleteMany(writableFilter(filter)) : col.deleteMany.bind(col),
295
946
  find: wrapReads ? async (filter) => {
296
947
  const docs = await col.find(filter);
297
- const migrated = docs.map(migrateRead);
298
- await persistAll(docs, migrated);
299
- return migrated.map(validateRead);
948
+ const normalized = docs.map(normalizeRead);
949
+ await persistAll(docs, normalized);
950
+ return normalized.map((n) => validateRead(n.value));
300
951
  } : col.find.bind(col),
301
952
  findOne: wrapReads ? async (filter) => {
302
953
  const doc = await col.findOne(filter);
303
954
  if (doc === null) return null;
304
- const migrated = migrateRead(doc);
305
- await persistAll([doc], [migrated]);
306
- return validateRead(migrated);
955
+ const normalized = normalizeRead(doc);
956
+ await persistAll([doc], [normalized]);
957
+ return validateRead(normalized.value);
307
958
  } : col.findOne.bind(col),
959
+ aggregate: wrapReads ? async (pipeline) => {
960
+ const docs = await col.aggregate(pipeline);
961
+ return pipelinePreservesDocuments(pipeline) ? normalizeViewRows(docs) : docs;
962
+ } : col.aggregate.bind(col),
308
963
  // Live queries feed every @taladb/react hook (useFind, useFindOne,
309
964
  // useQueries). Leaving them unwrapped meant React components received the
310
965
  // un-migrated shape while a direct find() returned the migrated one.
311
966
  subscribe: wrapReads ? (filter, callback, onError) => col.subscribe(
312
967
  filter,
313
968
  (docs) => {
314
- const migrated = docs.map(migrateRead);
969
+ const normalized = docs.map(normalizeRead);
315
970
  let out;
316
971
  try {
317
- out = migrated.map(validateRead);
972
+ out = normalized.map((n) => validateRead(n.value));
318
973
  } catch (err) {
319
974
  onError?.(err);
320
975
  return;
321
976
  }
322
977
  callback(out);
323
- void persistAll(docs, migrated);
978
+ void persistAll(docs, normalized);
324
979
  },
325
980
  onError
326
- ) : col.subscribe.bind(col)
981
+ ) : col.subscribe.bind(col),
982
+ subscribeAggregate: wrapReads ? (pipeline, callback, onError) => col.subscribeAggregate(
983
+ pipeline,
984
+ (docs) => {
985
+ if (!pipelinePreservesDocuments(pipeline)) {
986
+ callback(docs);
987
+ return;
988
+ }
989
+ try {
990
+ callback(normalizeViewRows(docs));
991
+ } catch (error) {
992
+ onError?.(error);
993
+ }
994
+ },
995
+ onError
996
+ ) : col.subscribeAggregate.bind(col)
327
997
  };
328
998
  }
329
999
  function detectPlatform() {
@@ -449,6 +1119,19 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
449
1119
  });
450
1120
  return JSON.parse(json);
451
1121
  },
1122
+ replaceManyWithIds: async (docs, origin = "local") => {
1123
+ const json = await proxy.send("replaceManyWithIds", {
1124
+ collection: name,
1125
+ docsJson: s(docs),
1126
+ origin
1127
+ });
1128
+ return JSON.parse(json);
1129
+ },
1130
+ deleteManyWithIds: (ids, origin = "local") => proxy.send("deleteManyWithIds", {
1131
+ collection: name,
1132
+ idsJson: s(ids),
1133
+ origin
1134
+ }),
452
1135
  find: async (filter) => {
453
1136
  const json = await proxy.send("find", {
454
1137
  collection: name,
@@ -520,59 +1203,96 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
520
1203
  });
521
1204
  return JSON.parse(json);
522
1205
  },
523
- subscribe: (filter, callback, onError) => {
524
- let active = true;
525
- let lastJson = "";
526
- let timer = null;
527
- let running = false;
528
- let rerun = false;
529
- const poll = async () => {
530
- if (!active) return;
531
- if (running) {
532
- rerun = true;
533
- return;
534
- }
535
- running = true;
536
- if (timer !== null) {
537
- clearTimeout(timer);
538
- timer = null;
539
- }
540
- try {
541
- const json = await proxy.send("find", {
542
- collection: name,
543
- filterJson: filter ? s(filter) : "null"
544
- });
545
- if (!active) return;
546
- if (json !== lastJson) {
547
- lastJson = json;
548
- callback(JSON.parse(json));
549
- }
550
- } catch (error) {
551
- if (active) onError?.(error);
552
- } finally {
553
- running = false;
554
- }
555
- if (active) {
556
- if (rerun) {
557
- rerun = false;
558
- void poll();
559
- } else timer = setTimeout(poll, 300);
560
- }
561
- };
562
- nudgeCallbacks.add(poll);
563
- poll();
564
- return () => {
565
- active = false;
566
- nudgeCallbacks.delete(poll);
567
- if (timer !== null) {
568
- clearTimeout(timer);
569
- timer = null;
570
- }
571
- };
572
- }
1206
+ searchText: async (field, query, topK, filter, options) => {
1207
+ const json = await proxy.send("searchText", {
1208
+ collection: name,
1209
+ field,
1210
+ query,
1211
+ topK,
1212
+ filterJson: filter ? JSON.stringify(filter) : "null",
1213
+ optionsJson: options ? JSON.stringify(options) : "null"
1214
+ });
1215
+ return JSON.parse(json);
1216
+ },
1217
+ hybridSearch: async (text, vector, topK, filter, options) => {
1218
+ const json = await proxy.send("hybridSearch", {
1219
+ collection: name,
1220
+ textField: text.textField,
1221
+ text: text.text,
1222
+ vectorField: vector.vectorField,
1223
+ vectorJson: JSON.stringify(vector.vector),
1224
+ topK,
1225
+ filterJson: filter ? JSON.stringify(filter) : "null",
1226
+ optionsJson: options ? JSON.stringify(options) : "null"
1227
+ });
1228
+ return JSON.parse(json);
1229
+ },
1230
+ subscribe: (filter, callback, onError) => nudgedPoller(
1231
+ () => proxy.send("find", {
1232
+ collection: name,
1233
+ filterJson: filter ? s(filter) : "null"
1234
+ }),
1235
+ callback,
1236
+ onError
1237
+ ),
1238
+ subscribeAggregate: (pipeline, callback, onError) => nudgedPoller(
1239
+ () => proxy.send("aggregate", {
1240
+ collection: name,
1241
+ pipelineJson: s(pipeline)
1242
+ }),
1243
+ callback,
1244
+ onError
1245
+ )
573
1246
  };
574
1247
  return opts ? applySchema(wrapped, opts) : wrapped;
575
1248
  }
1249
+ function nudgedPoller(fetchJson, callback, onError) {
1250
+ let active = true;
1251
+ let lastJson = "";
1252
+ let timer = null;
1253
+ let running = false;
1254
+ let rerun = false;
1255
+ const poll = async () => {
1256
+ if (!active) return;
1257
+ if (running) {
1258
+ rerun = true;
1259
+ return;
1260
+ }
1261
+ running = true;
1262
+ if (timer !== null) {
1263
+ clearTimeout(timer);
1264
+ timer = null;
1265
+ }
1266
+ try {
1267
+ const json = await fetchJson();
1268
+ if (!active) return;
1269
+ if (json !== lastJson) {
1270
+ lastJson = json;
1271
+ callback(JSON.parse(json));
1272
+ }
1273
+ } catch (error) {
1274
+ if (active) onError?.(error);
1275
+ } finally {
1276
+ running = false;
1277
+ }
1278
+ if (active) {
1279
+ if (rerun) {
1280
+ rerun = false;
1281
+ void poll();
1282
+ } else timer = setTimeout(poll, 300);
1283
+ }
1284
+ };
1285
+ nudgeCallbacks.add(poll);
1286
+ poll();
1287
+ return () => {
1288
+ active = false;
1289
+ nudgeCallbacks.delete(poll);
1290
+ if (timer !== null) {
1291
+ clearTimeout(timer);
1292
+ timer = null;
1293
+ }
1294
+ };
1295
+ }
576
1296
  const handle = {
577
1297
  collection: (name, opts) => wrapCollection(name, opts),
578
1298
  compact: () => proxy.send("compact"),
@@ -624,6 +1344,8 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
624
1344
  const wrapped = {
625
1345
  insert: async (doc) => col.insertAsync ? col.insertAsync(doc) : col.insert(doc),
626
1346
  insertMany: async (docs) => col.insertManyAsync ? col.insertManyAsync(docs) : col.insertMany(docs),
1347
+ replaceManyWithIds: async (docs, origin = "local") => col.replaceManyWithIdsAsync ? col.replaceManyWithIdsAsync(docs, origin) : col.replaceManyWithIds(docs, origin),
1348
+ deleteManyWithIds: async (ids, origin = "local") => col.deleteManyWithIdsAsync ? col.deleteManyWithIdsAsync(ids, origin) : col.deleteManyWithIds(ids, origin),
627
1349
  find: async (filter) => col.findAsync ? col.findAsync(filter ?? null) : col.find(filter ?? null),
628
1350
  findOne: async (filter) => col.findOne(filter) ?? null,
629
1351
  updateOne: async (filter, update) => col.updateOneAsync ? col.updateOneAsync(filter, update) : col.updateOne(filter, update),
@@ -649,7 +1371,14 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
649
1371
  const raw = await col.findNearest(field, vector, topK, filter ?? null);
650
1372
  return raw;
651
1373
  },
652
- subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError)
1374
+ searchText: async (field, query, topK, filter, options) => {
1375
+ return col.searchText(field, query, topK, filter ?? null, options ?? null);
1376
+ },
1377
+ hybridSearch: async (text, vector, topK, filter, options) => {
1378
+ return col.hybridSearch(text.textField, text.text, vector.vectorField, vector.vector, topK, filter ?? null, options ?? null);
1379
+ },
1380
+ subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError),
1381
+ subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => wrapped.aggregate(pipeline), callback, onError)
653
1382
  };
654
1383
  return opts ? applySchema(wrapped, opts) : wrapped;
655
1384
  }
@@ -697,6 +1426,8 @@ async function createNativeDB(_dbName, migrations) {
697
1426
  const wrapped = {
698
1427
  insert: async (doc) => native.insert(name, doc),
699
1428
  insertMany: async (docs) => native.insertMany(name, docs),
1429
+ replaceManyWithIds: async (docs, origin = "local") => native.replaceManyWithIds(name, docs, origin),
1430
+ deleteManyWithIds: async (ids, origin = "local") => native.deleteManyWithIds(name, ids, origin),
700
1431
  find: async (filter) => native.find(name, filter ?? {}),
701
1432
  findOne: async (filter) => native.findOne(name, filter ?? {}),
702
1433
  updateOne: async (filter, update) => native.updateOne(name, filter, update),
@@ -728,7 +1459,20 @@ async function createNativeDB(_dbName, migrations) {
728
1459
  const raw = native.findNearest(name, field, vector, topK, filter ?? null);
729
1460
  return raw;
730
1461
  },
731
- subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError)
1462
+ searchText: async (field, query, topK, filter, options) => {
1463
+ if (!native.searchText) {
1464
+ throw new Error("searchText requires @taladb/react-native \u2265 0.10 \u2014 rebuild the native module");
1465
+ }
1466
+ return native.searchText(name, field, query, topK, filter ?? null, options ?? null);
1467
+ },
1468
+ hybridSearch: async (text, vector, topK, filter, options) => {
1469
+ if (!native.hybridSearch) {
1470
+ throw new Error("hybridSearch requires @taladb/react-native \u2265 0.10 \u2014 rebuild the native module");
1471
+ }
1472
+ return native.hybridSearch(name, text.textField, text.text, vector.vectorField, vector.vector, topK, filter ?? null, options ?? null);
1473
+ },
1474
+ subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError),
1475
+ subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => native.aggregate(name, pipeline), callback, onError)
732
1476
  };
733
1477
  return opts ? applySchema(wrapped, opts) : wrapped;
734
1478
  }
@@ -824,9 +1568,20 @@ async function openDB(dbName = "taladb.db", options) {
824
1568
  }
825
1569
  }
826
1570
  export {
1571
+ COVERAGE_COLLECTION,
1572
+ CoverageStore,
827
1573
  HttpSyncAdapter,
1574
+ REPLICA_REVISION_FIELD,
1575
+ REPLICA_SCOPE_FIELD,
1576
+ ReplicationCoordinator,
828
1577
  TalaDbValidationError,
829
1578
  applySchema,
1579
+ coverageKey,
1580
+ createRestSource,
1581
+ deriveDocId,
1582
+ isAuthoritative,
830
1583
  openDB,
1584
+ progress,
1585
+ rowsApplied,
831
1586
  runMigrations
832
1587
  };