taladb 0.9.2 → 0.9.4

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.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 { pushMs: doc?.pushMs ?? 0, pullMs: doc?.pullMs ?? 0 };
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(`sync direction '${direction}' requires adapter.pull()`);
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
- const remote = await adapter.pull(0);
173
- if (remote && remote !== "[]") {
174
- if (useValidated) {
175
- const report = await handle.importChangesValidated(remote, JSON.stringify(scopedSchemas));
176
- pulled = report.applied;
177
- skipped = report.skipped;
178
- quarantined = report.quarantined;
179
- } else {
180
- pulled = await handle.importChanges(remote);
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 {
@@ -279,6 +791,10 @@ function applySchema(col, options) {
279
791
  if (!stampVersion || doc._v !== void 0) return doc;
280
792
  return { ...doc, _v: targetVersion };
281
793
  }
794
+ function stampDoc(doc) {
795
+ if (!stampVersion || doc._v !== void 0) return doc;
796
+ return { ...doc, _v: targetVersion };
797
+ }
282
798
  function diffUpdate(original, migrated) {
283
799
  const $set = {};
284
800
  const $unset = {};
@@ -333,6 +849,19 @@ function applySchema(col, options) {
333
849
  if (schema) docs.forEach((doc, i) => parseWrite(doc, `insertMany[${i}]`));
334
850
  return col.insertMany(docs.map(stamp));
335
851
  } : col.insertMany.bind(col),
852
+ // Rows arriving from a remote origin are validated like any other write. This
853
+ // is the "parse, don't assert" boundary: the compile-time generic and the
854
+ // runtime schema check have to be the same seam, or a malformed server
855
+ // response walks straight into a typed collection.
856
+ replaceManyWithIds: wrapWrites ? async (docs, origin) => {
857
+ if (schema) docs.forEach((doc, i) => {
858
+ const { _replica_scope, _remote_rev, ...schemaDoc } = doc;
859
+ void _replica_scope;
860
+ void _remote_rev;
861
+ parseWrite(schemaDoc, `replaceManyWithIds[${i}]`);
862
+ });
863
+ return col.replaceManyWithIds(docs.map((d) => stampDoc(d)), origin);
864
+ } : col.replaceManyWithIds.bind(col),
336
865
  find: wrapReads ? async (filter) => {
337
866
  const docs = await col.find(filter);
338
867
  const migrated = docs.map(migrateRead);
@@ -490,6 +1019,19 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
490
1019
  });
491
1020
  return JSON.parse(json);
492
1021
  },
1022
+ replaceManyWithIds: async (docs, origin = "local") => {
1023
+ const json = await proxy.send("replaceManyWithIds", {
1024
+ collection: name,
1025
+ docsJson: s(docs),
1026
+ origin
1027
+ });
1028
+ return JSON.parse(json);
1029
+ },
1030
+ deleteManyWithIds: (ids, origin = "local") => proxy.send("deleteManyWithIds", {
1031
+ collection: name,
1032
+ idsJson: s(ids),
1033
+ origin
1034
+ }),
493
1035
  find: async (filter) => {
494
1036
  const json = await proxy.send("find", {
495
1037
  collection: name,
@@ -561,59 +1103,72 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
561
1103
  });
562
1104
  return JSON.parse(json);
563
1105
  },
564
- subscribe: (filter, callback, onError) => {
565
- let active = true;
566
- let lastJson = "";
567
- let timer = null;
568
- let running = false;
569
- let rerun = false;
570
- const poll = async () => {
571
- if (!active) return;
572
- if (running) {
573
- rerun = true;
574
- return;
575
- }
576
- running = true;
577
- if (timer !== null) {
578
- clearTimeout(timer);
579
- timer = null;
580
- }
581
- try {
582
- const json = await proxy.send("find", {
583
- collection: name,
584
- filterJson: filter ? s(filter) : "null"
585
- });
586
- if (!active) return;
587
- if (json !== lastJson) {
588
- lastJson = json;
589
- callback(JSON.parse(json));
590
- }
591
- } catch (error) {
592
- if (active) onError?.(error);
593
- } finally {
594
- running = false;
595
- }
596
- if (active) {
597
- if (rerun) {
598
- rerun = false;
599
- void poll();
600
- } else timer = setTimeout(poll, 300);
601
- }
602
- };
603
- nudgeCallbacks.add(poll);
604
- poll();
605
- return () => {
606
- active = false;
607
- nudgeCallbacks.delete(poll);
608
- if (timer !== null) {
609
- clearTimeout(timer);
610
- timer = null;
611
- }
612
- };
613
- }
1106
+ subscribe: (filter, callback, onError) => nudgedPoller(
1107
+ () => proxy.send("find", {
1108
+ collection: name,
1109
+ filterJson: filter ? s(filter) : "null"
1110
+ }),
1111
+ callback,
1112
+ onError
1113
+ ),
1114
+ subscribeAggregate: (pipeline, callback, onError) => nudgedPoller(
1115
+ () => proxy.send("aggregate", {
1116
+ collection: name,
1117
+ pipelineJson: s(pipeline)
1118
+ }),
1119
+ callback,
1120
+ onError
1121
+ )
614
1122
  };
615
1123
  return opts ? applySchema(wrapped, opts) : wrapped;
616
1124
  }
1125
+ function nudgedPoller(fetchJson, callback, onError) {
1126
+ let active = true;
1127
+ let lastJson = "";
1128
+ let timer = null;
1129
+ let running = false;
1130
+ let rerun = false;
1131
+ const poll = async () => {
1132
+ if (!active) return;
1133
+ if (running) {
1134
+ rerun = true;
1135
+ return;
1136
+ }
1137
+ running = true;
1138
+ if (timer !== null) {
1139
+ clearTimeout(timer);
1140
+ timer = null;
1141
+ }
1142
+ try {
1143
+ const json = await fetchJson();
1144
+ if (!active) return;
1145
+ if (json !== lastJson) {
1146
+ lastJson = json;
1147
+ callback(JSON.parse(json));
1148
+ }
1149
+ } catch (error) {
1150
+ if (active) onError?.(error);
1151
+ } finally {
1152
+ running = false;
1153
+ }
1154
+ if (active) {
1155
+ if (rerun) {
1156
+ rerun = false;
1157
+ void poll();
1158
+ } else timer = setTimeout(poll, 300);
1159
+ }
1160
+ };
1161
+ nudgeCallbacks.add(poll);
1162
+ poll();
1163
+ return () => {
1164
+ active = false;
1165
+ nudgeCallbacks.delete(poll);
1166
+ if (timer !== null) {
1167
+ clearTimeout(timer);
1168
+ timer = null;
1169
+ }
1170
+ };
1171
+ }
617
1172
  const handle = {
618
1173
  collection: (name, opts) => wrapCollection(name, opts),
619
1174
  compact: () => proxy.send("compact"),
@@ -665,6 +1220,8 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
665
1220
  const wrapped = {
666
1221
  insert: async (doc) => col.insertAsync ? col.insertAsync(doc) : col.insert(doc),
667
1222
  insertMany: async (docs) => col.insertManyAsync ? col.insertManyAsync(docs) : col.insertMany(docs),
1223
+ replaceManyWithIds: async (docs, origin = "local") => col.replaceManyWithIdsAsync ? col.replaceManyWithIdsAsync(docs, origin) : col.replaceManyWithIds(docs, origin),
1224
+ deleteManyWithIds: async (ids, origin = "local") => col.deleteManyWithIdsAsync ? col.deleteManyWithIdsAsync(ids, origin) : col.deleteManyWithIds(ids, origin),
668
1225
  find: async (filter) => col.findAsync ? col.findAsync(filter ?? null) : col.find(filter ?? null),
669
1226
  findOne: async (filter) => col.findOne(filter) ?? null,
670
1227
  updateOne: async (filter, update) => col.updateOneAsync ? col.updateOneAsync(filter, update) : col.updateOne(filter, update),
@@ -690,7 +1247,8 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
690
1247
  const raw = await col.findNearest(field, vector, topK, filter ?? null);
691
1248
  return raw;
692
1249
  },
693
- subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError)
1250
+ subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError),
1251
+ subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => wrapped.aggregate(pipeline), callback, onError)
694
1252
  };
695
1253
  return opts ? applySchema(wrapped, opts) : wrapped;
696
1254
  }
@@ -738,6 +1296,8 @@ async function createNativeDB(_dbName, migrations) {
738
1296
  const wrapped = {
739
1297
  insert: async (doc) => native.insert(name, doc),
740
1298
  insertMany: async (docs) => native.insertMany(name, docs),
1299
+ replaceManyWithIds: async (docs, origin = "local") => native.replaceManyWithIds(name, docs, origin),
1300
+ deleteManyWithIds: async (ids, origin = "local") => native.deleteManyWithIds(name, ids, origin),
741
1301
  find: async (filter) => native.find(name, filter ?? {}),
742
1302
  findOne: async (filter) => native.findOne(name, filter ?? {}),
743
1303
  updateOne: async (filter, update) => native.updateOne(name, filter, update),
@@ -769,7 +1329,8 @@ async function createNativeDB(_dbName, migrations) {
769
1329
  const raw = native.findNearest(name, field, vector, topK, filter ?? null);
770
1330
  return raw;
771
1331
  },
772
- subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError)
1332
+ subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError),
1333
+ subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => native.aggregate(name, pipeline), callback, onError)
773
1334
  };
774
1335
  return opts ? applySchema(wrapped, opts) : wrapped;
775
1336
  }
@@ -866,9 +1427,20 @@ async function openDB(dbName = "taladb.db", options) {
866
1427
  }
867
1428
  // Annotate the CommonJS export names for ESM import in node:
868
1429
  0 && (module.exports = {
1430
+ COVERAGE_COLLECTION,
1431
+ CoverageStore,
869
1432
  HttpSyncAdapter,
1433
+ REPLICA_REVISION_FIELD,
1434
+ REPLICA_SCOPE_FIELD,
1435
+ ReplicationCoordinator,
870
1436
  TalaDbValidationError,
871
1437
  applySchema,
1438
+ coverageKey,
1439
+ createRestSource,
1440
+ deriveDocId,
1441
+ isAuthoritative,
872
1442
  openDB,
1443
+ progress,
1444
+ rowsApplied,
873
1445
  runMigrations
874
1446
  });