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.d.mts CHANGED
@@ -43,17 +43,40 @@ type Document = {
43
43
  _id?: string;
44
44
  [key: string]: Value | undefined;
45
45
  };
46
- type FieldOps<T> = T extends null | undefined ? {
47
- $exists?: boolean;
48
- } : {
49
- $eq?: T;
50
- $ne?: T;
51
- $gt?: T;
52
- $gte?: T;
53
- $lt?: T;
54
- $lte?: T;
55
- $in?: T[];
56
- $nin?: T[];
46
+ /**
47
+ * Who authored a write, and therefore whether it replicates outward.
48
+ *
49
+ * - `'local'` *(default)* — an ordinary user write. Replicates to peers as usual.
50
+ * - `'remote'` — a row replicated **in** from an authoritative origin. The origin
51
+ * already has it, so it must never go back out: rows written this way fire no
52
+ * sync events and never appear in `exportChanges()`, and deletes made this way
53
+ * leave no tombstone. Enforced in the engine, not by convention.
54
+ */
55
+ type WriteOrigin = 'local' | 'remote';
56
+ /**
57
+ * The operators available on a single field.
58
+ *
59
+ * Deliberately **not** a conditional type. `T extends null | undefined ? … : …`
60
+ * is *distributive*, so for a union-typed field (`type: 'Cabin' | 'Villa' | …`)
61
+ * it spreads over every member and infers `$in?: 'Cabin'[] | 'Villa'[] | …`
62
+ * instead of `$in?: ('Cabin' | 'Villa' | …)[]` — making `$in` unusable on any
63
+ * union field without a cast.
64
+ *
65
+ * Tuple-wrapping the check (`[T] extends [null | undefined]`) stops the
66
+ * distribution but then strips `$exists` from optional fields, which is the one
67
+ * thing the conditional existed for. So there is no conditional at all: `$exists`
68
+ * is always available (it is meaningful on every field), and the value operators
69
+ * use `NonNullable<T>` so an optional field still compares against its real type.
70
+ */
71
+ type FieldOps<T> = {
72
+ $eq?: NonNullable<T>;
73
+ $ne?: NonNullable<T>;
74
+ $gt?: NonNullable<T>;
75
+ $gte?: NonNullable<T>;
76
+ $lt?: NonNullable<T>;
77
+ $lte?: NonNullable<T>;
78
+ $in?: NonNullable<T>[];
79
+ $nin?: NonNullable<T>[];
57
80
  $exists?: boolean;
58
81
  /** Full-text search: matches documents where this string field contains the given token. */
59
82
  $contains?: string;
@@ -227,7 +250,14 @@ type AggregateStage<T extends Document = Document> = {
227
250
  $skip: number;
228
251
  } | {
229
252
  $limit: number;
230
- } | {
253
+ }
254
+ /**
255
+ * Reshape each document. Either an **inclusion** (`{ name: 1, city: 1 }` —
256
+ * keep only these) or an **exclusion** (`{ description: 0 }` — keep everything
257
+ * else). The two cannot be mixed and doing so throws; `_id: 0` is the one
258
+ * exclusion allowed alongside an inclusion.
259
+ */
260
+ | {
231
261
  $project: Record<string, 0 | 1>;
232
262
  };
233
263
  /** An ordered aggregation pipeline. */
@@ -235,6 +265,39 @@ type AggregatePipeline<T extends Document = Document> = AggregateStage<T>[];
235
265
  interface Collection<T extends Document = Document> {
236
266
  insert(doc: Omit<T, '_id'>): Promise<string>;
237
267
  insertMany(docs: Omit<T, '_id'>[]): Promise<string[]>;
268
+ /**
269
+ * Upsert many documents **by `_id`**, in a single commit. Existing rows are
270
+ * replaced in place, absent rows are created, and rows not named in `docs` are
271
+ * left alone — so writing page 2 never disturbs page 1.
272
+ *
273
+ * Unlike {@link insertMany}, which discards `_id` and mints a fresh ULID, this
274
+ * *honours* the id you supply. That is the whole point: for a row replicated
275
+ * from a remote origin, pass `_id: deriveDocId(collection, remoteKey)` and every
276
+ * later fetch of that row converges on the same document instead of duplicating
277
+ * it. Idempotent, and safe to run concurrently from a background hydration walk
278
+ * and an on-demand fetch.
279
+ *
280
+ * `origin: 'remote'` marks the rows as replicated in from an authoritative
281
+ * origin, which means they are **never replicated back out** — they will not
282
+ * fire sync events and will not appear in `exportChanges()`. Use it for anything
283
+ * the origin already knows about. Defaults to `'local'`.
284
+ *
285
+ * @example
286
+ * await products.replaceManyWithIds(
287
+ * rows.map((r) => ({ ...r, _id: deriveDocId('products', r.id) })),
288
+ * 'remote',
289
+ * );
290
+ */
291
+ replaceManyWithIds(docs: T[], origin?: WriteOrigin): Promise<string[]>;
292
+ /**
293
+ * Delete many documents by `_id`, in a single commit. Returns how many were
294
+ * present and removed; unknown ids are skipped.
295
+ *
296
+ * `origin: 'remote'` deletes **without a tombstone**, so the deletion is not
297
+ * replicated outward — correct when the origin is the one that told you the row
298
+ * was deleted. Defaults to `'local'`, which tombstones as usual.
299
+ */
300
+ deleteManyWithIds(ids: string[], origin?: WriteOrigin): Promise<number>;
238
301
  find(filter?: Filter<T>): Promise<T[]>;
239
302
  findOne(filter: Filter<T>): Promise<T | null>;
240
303
  updateOne(filter: Filter<T>, update: Update<T>): Promise<boolean>;
@@ -245,7 +308,12 @@ interface Collection<T extends Document = Document> {
245
308
  /**
246
309
  * Run a MongoDB-style aggregation pipeline (`$match`, `$group`, `$sort`,
247
310
  * `$skip`, `$limit`, `$project`) inside the engine. Returns the resulting
248
- * documents. Currently available on Node.js and the in-memory browser build.
311
+ * documents. Available on every runtime: Node, the OPFS worker, the in-memory
312
+ * browser build, and React Native.
313
+ *
314
+ * This is also how you page a collection locally — `find()` has no sort/skip/
315
+ * limit — but note it returns a **snapshot**. For a paged read that stays live
316
+ * as rows land, use {@link subscribeAggregate}.
249
317
  *
250
318
  * @example
251
319
  * const byStatus = await orders.aggregate([
@@ -343,6 +411,28 @@ interface Collection<T extends Document = Document> {
343
411
  * unsub();
344
412
  */
345
413
  subscribe(filter: Filter<T>, callback: (docs: T[]) => void, onError?: (error: unknown) => void): () => void;
414
+ /**
415
+ * Subscribe to a live **aggregation** — the same as {@link subscribe}, but the
416
+ * result set is produced by a pipeline rather than a filter.
417
+ *
418
+ * This exists because {@link aggregate} is the only way to sort/skip/limit, and
419
+ * on its own it returns a dead snapshot: a paged read built on it would never
420
+ * re-run when new rows land, so a page would sit frozen while a background
421
+ * hydration filled the collection underneath it. Anything that pages locally
422
+ * should subscribe here instead of calling `aggregate` in an effect.
423
+ *
424
+ * The callback receives a snapshot immediately and again after every write that
425
+ * could affect the result.
426
+ *
427
+ * @returns An unsubscribe function.
428
+ *
429
+ * @example
430
+ * const unsub = products.subscribeAggregate(
431
+ * [{ $match: { category: 'kitchen' } }, { $sort: { price: 1 } }, { $limit: 20 }],
432
+ * (page) => render(page),
433
+ * );
434
+ */
435
+ subscribeAggregate<R extends Document = Document>(pipeline: AggregatePipeline<T>, callback: (docs: R[]) => void, onError?: (error: unknown) => void): () => void;
346
436
  }
347
437
  /**
348
438
  * A JSON-encoded changeset — the opaque payload exchanged between peers. Produced
@@ -364,9 +454,58 @@ interface SyncAdapter {
364
454
  * Fetch remote changes with `changed_at` after `sinceMs` (ms epoch), as a
365
455
  * serialized changeset. Return `'[]'` when there is nothing new. Required for
366
456
  * `'pull'` / `'both'`.
457
+ *
458
+ * @deprecated in spirit, not in support — wall-clock timestamps are not safe
459
+ * cursors (see {@link CursorSyncAdapter}), which is why every pass built on this
460
+ * method replays the whole collection from zero. Implement
461
+ * {@link CursorSyncAdapter.pullWithCursor} instead when your origin can issue a
462
+ * cursor. Adapters that only implement `pull` keep working unchanged.
367
463
  */
368
464
  pull?(sinceMs: number): Promise<SerializedChangeset>;
369
465
  }
466
+ /** One page of remote changes, plus where to resume from. */
467
+ interface PullResult {
468
+ /** The changes themselves. `'[]'` when there is nothing new. */
469
+ changeset: SerializedChangeset;
470
+ /**
471
+ * Opaque resume token, issued by the origin. **Never parse this.** It may be a
472
+ * timestamp, a sequence number, an LSN, a snapshot id — that is the origin's
473
+ * business, and treating it as a number is how clients reintroduce the
474
+ * clock-skew bug this type exists to kill.
475
+ */
476
+ cursor: string;
477
+ /** `true` when more pages remain; call again with the returned `cursor`. */
478
+ hasMore: boolean;
479
+ }
480
+ /**
481
+ * A {@link SyncAdapter} whose origin can issue a resume cursor.
482
+ *
483
+ * ## Why this exists
484
+ *
485
+ * The original contract is `pull(sinceMs)`, and it cannot be made correct. Author
486
+ * wall-clock timestamps are not safe cursors: a write can commit *after* an export
487
+ * yet carry an *earlier* timestamp, so resuming from "the newest timestamp I saw"
488
+ * silently drops rows. TalaDB's answer was to give up on cursors entirely and
489
+ * replay from zero on every pass — correct, but it re-downloads the whole
490
+ * collection forever, which makes a full local replica of a real catalog
491
+ * unaffordable.
492
+ *
493
+ * The fix is to stop inventing the cursor on the client. The origin issues an
494
+ * opaque token; we store it and hand it back. Whatever ordering guarantee the
495
+ * origin has (a sequence, an LSN, a snapshot) travels with the token, and the
496
+ * client never has to reason about clocks at all.
497
+ *
498
+ * `runSync` feature-detects `pullWithCursor` and prefers it. Adapters that only
499
+ * implement `pull(sinceMs)` are untouched and keep their replay-from-zero
500
+ * behavior.
501
+ */
502
+ interface CursorSyncAdapter extends SyncAdapter {
503
+ /**
504
+ * Fetch changes after `cursor`, or from the beginning when it is `null`.
505
+ * Returns the changes plus the token to resume from next time.
506
+ */
507
+ pullWithCursor(cursor: string | null): Promise<PullResult>;
508
+ }
370
509
  interface SyncOptions {
371
510
  /**
372
511
  * Collections to sync. Omit to sync **all** user collections (reserved
@@ -573,6 +712,504 @@ declare class HttpSyncAdapter implements SyncAdapter {
573
712
  pull(sinceMs: number): Promise<SerializedChangeset>;
574
713
  }
575
714
 
715
+ /**
716
+ * Deterministic document ids for replicated rows.
717
+ *
718
+ * The engine assigns ULIDs and **ignores a caller-supplied `_id`** — it silently
719
+ * becomes an ordinary field, so `find({ _id: 'sku-1' })` then matches nothing.
720
+ * That leaves a document replicated from a remote origin with no stable local
721
+ * identity to merge on: re-fetching the same row would insert a duplicate.
722
+ *
723
+ * Hashing the origin's primary key into the ULID gives that identity back. The
724
+ * same `(collection, key)` always maps to the same document, which is what makes
725
+ * replication upserts **idempotent** (re-applying a page is a no-op), **resumable**
726
+ * (a bootstrap walk can restart mid-way), and **safe to run concurrently** (an
727
+ * on-demand fetch and the background walk can touch the same row and converge on
728
+ * one document rather than two).
729
+ *
730
+ * ## This must stay byte-identical to the Rust `derive_doc_id`
731
+ *
732
+ * The same rows are addressed from both sides. If the two implementations ever
733
+ * disagree, two clients assign different `_id`s to the same remote row and the
734
+ * replica silently forks into duplicates — with no error anywhere. The shared
735
+ * test vectors in `derive-id.test.ts` and `packages/core/src/document.rs` exist to
736
+ * make that impossible to do by accident; keep them in lockstep.
737
+ *
738
+ * FNV-1a is used over a stronger hash precisely *because* it is short enough to
739
+ * port between the two languages without ambiguity. It is non-cryptographic, which
740
+ * is fine here: the input is a primary key from an origin the client already
741
+ * trusts, not adversarial input.
742
+ */
743
+ /**
744
+ * Derive a stable `_id` for a row replicated from a remote origin.
745
+ *
746
+ * `collection` is part of the preimage, so the same remote id in two different
747
+ * collections cannot collide.
748
+ *
749
+ * @example
750
+ * deriveDocId('products', 'sku-123') // → '56GC678DQYWW1Z98HPYJ90WVKH', always
751
+ *
752
+ * ## Ordering caveat
753
+ *
754
+ * The result is a hash, so its ULID timestamp prefix is **not** chronological.
755
+ * Documents written with a derived id do not come back in insertion order from an
756
+ * unsorted `find()`; reads over replicated collections must carry an explicit
757
+ * sort. Documents written via `insert`/`insertMany` are unaffected — they still
758
+ * get monotonic ULIDs.
759
+ */
760
+ declare function deriveDocId(collection: string, key: string): string;
761
+
762
+ /**
763
+ * Coverage — "is this collection complete enough, locally, to answer a query
764
+ * without the network?"
765
+ *
766
+ * This is the question the whole coverage-first design turns on, and it is *not*
767
+ * "have I fetched this page?". A replica assembled from whichever pages a user
768
+ * happened to visit is an arbitrary partial subset: it cannot answer a query
769
+ * nobody has asked yet ("products under ₱500" may live on page 43), so every new
770
+ * filter or sort still goes to the network and the local database buys you almost
771
+ * nothing. Coverage is what licenses a purely local read.
772
+ *
773
+ * Two things make it trustworthy:
774
+ *
775
+ * 1. **It is scoped, not per-collection.** `complete` for a bare collection name
776
+ * would leak across users: log in as someone else and you inherit the previous
777
+ * user's "complete" flag *and* their rows. The key is a tuple.
778
+ * 2. **It is a state machine, not a boolean.** Only `complete` authorizes a
779
+ * local-only read. `best-effort` exists precisely so an origin that *cannot*
780
+ * give us a consistent snapshot degrades honestly instead of claiming a
781
+ * completeness it never established.
782
+ */
783
+
784
+ /** Reserved collection holding one coverage document per replicated scope. */
785
+ declare const COVERAGE_COLLECTION = "__taladb_replica";
786
+ /**
787
+ * What identifies a replicated scope. Every component must be part of the key,
788
+ * because each one changes what "complete" means:
789
+ *
790
+ * - `origin` — two origins are two different datasets.
791
+ * - `collection` — the local collection being filled.
792
+ * - `scope` — the *authorization* slice (a user, a tenant, a store). This is the
793
+ * one that bites: without it, user B logging in inherits user A's completeness.
794
+ * - `projectionVersion` — a replica hydrated with a slimmer projection is not
795
+ * complete for a query that needs the dropped fields.
796
+ * - `schemaVersion` — rows hydrated under an older shape may not satisfy today's.
797
+ */
798
+ interface CoverageKey {
799
+ origin: string;
800
+ collection: string;
801
+ scope: string;
802
+ projectionVersion: number;
803
+ schemaVersion: number;
804
+ }
805
+ type CoverageState =
806
+ /** Nothing local. */
807
+ {
808
+ status: 'empty';
809
+ }
810
+ /**
811
+ * A bootstrap walk is in progress. `snapshot` pins every page to one logical
812
+ * view of the origin; `nextPage` is the durable resume point.
813
+ */
814
+ | {
815
+ status: 'hydrating';
816
+ snapshot: string;
817
+ nextPage: string | number;
818
+ rowsApplied: number;
819
+ deltaCursor?: string;
820
+ total?: number;
821
+ }
822
+ /**
823
+ * The scope is fully local as of `cursor`. **The only state that permits a
824
+ * local-only read.**
825
+ */
826
+ | {
827
+ status: 'complete';
828
+ cursor: string;
829
+ completedAt: number;
830
+ rowsApplied: number;
831
+ total?: number;
832
+ }
833
+ /**
834
+ * Every row the origin offered was applied, but the origin could not pin a
835
+ * snapshot, so we cannot *prove* we saw a consistent view — a row that shifted
836
+ * between pages mid-walk may have been missed. Reads must not treat this as
837
+ * authoritative.
838
+ */
839
+ | {
840
+ status: 'best-effort';
841
+ cursor: string;
842
+ reason: string;
843
+ rowsApplied: number;
844
+ total?: number;
845
+ }
846
+ /** Complete once, but known to have fallen behind (e.g. a projection change). */
847
+ | {
848
+ status: 'stale';
849
+ cursor: string;
850
+ reason: string;
851
+ }
852
+ /** The walk failed. `resumeFrom` is where to pick it up. */
853
+ | {
854
+ status: 'error';
855
+ resumeFrom: string | number;
856
+ snapshot?: string;
857
+ deltaCursor?: string;
858
+ rowsApplied?: number;
859
+ total?: number;
860
+ error: string;
861
+ };
862
+ /**
863
+ * Serialize a {@link CoverageKey} into a stable string.
864
+ *
865
+ * Field order is fixed rather than derived from `Object.keys`, so the key cannot
866
+ * change meaning if someone reorders the interface — a silent coverage reset,
867
+ * which would look like "the app re-downloads everything for no reason".
868
+ */
869
+ declare function coverageKey(key: CoverageKey): string;
870
+ /**
871
+ * Persistent coverage state, one document per scope.
872
+ *
873
+ * The state is stored as a JSON string rather than as structured fields: it is a
874
+ * discriminated union whose shape varies per variant, and TalaDB documents are
875
+ * flat. Writing it whole also makes each transition a single atomic write, which
876
+ * is what lets `markComplete` be the durable commit point of a bootstrap.
877
+ */
878
+ declare class CoverageStore {
879
+ private readonly col;
880
+ constructor(db: TalaDB);
881
+ read(key: CoverageKey): Promise<CoverageState>;
882
+ write(key: CoverageKey, state: CoverageState): Promise<void>;
883
+ /** Drop a scope's coverage, forcing a fresh bootstrap on next use. */
884
+ clear(key: CoverageKey): Promise<void>;
885
+ }
886
+ /**
887
+ * Whether a local-only read is authorized for this state.
888
+ *
889
+ * Deliberately strict: **only `complete`**. `best-effort` is the interesting
890
+ * exclusion — it means we applied everything the origin gave us, but the origin
891
+ * could not pin a snapshot, so a row that moved between pages during the walk may
892
+ * never have been seen. Serving that as authoritative would silently return
893
+ * incomplete results, which is worse than going to the network.
894
+ */
895
+ declare function isAuthoritative(state: CoverageState): boolean;
896
+ /** Rows applied so far, for progress reporting. */
897
+ declare function rowsApplied(state: CoverageState): number;
898
+ /** Fractional hydration progress, when the origin told us the total. */
899
+ declare function progress(state: CoverageState): number | undefined;
900
+
901
+ /**
902
+ * The replication *source* — wire translation, and nothing else.
903
+ *
904
+ * A source knows how to talk to one origin: how to ask for a page, how to ask for
905
+ * changes since a cursor, how to find a row's primary key, and how to shape a row
906
+ * into a document. It owns **no orchestration**: no batching, no yielding, no
907
+ * cursor persistence, no coverage transitions, no retry, no dedup. All of that
908
+ * belongs to the coordinator, which is generic over sources.
909
+ *
910
+ * That split is deliberate. The obvious alternative — make the REST origin a
911
+ * `SyncAdapter` and let `db.sync()` drive it — does not work: a bootstrap of 100k
912
+ * rows would sit inside a single `pull()` call with no way to report progress,
913
+ * pause, resume, or yield to the UI between pages. Orchestration has to live one
914
+ * level up, or it cannot be orchestrated at all.
915
+ */
916
+
917
+ /** The origin's primary key for a row. Stringified before hashing into an id. */
918
+ type RemoteKey = string;
919
+ /** A request for one page of the initial bootstrap walk. */
920
+ interface BootstrapRequest {
921
+ /**
922
+ * Where to resume. `null` on the first call — which is also when the origin is
923
+ * expected to *issue* the snapshot and delta cursor.
924
+ */
925
+ page: string | number | null;
926
+ /**
927
+ * The snapshot token from the first page, echoed back on every subsequent one.
928
+ * `null` on the first call, and on origins that don't support snapshots.
929
+ */
930
+ snapshot: string | null;
931
+ /** Rows per page. */
932
+ limit: number;
933
+ }
934
+ /** One page of the bootstrap walk. */
935
+ interface BootstrapPage<RemoteRow> {
936
+ rows: RemoteRow[];
937
+ /** Resume token for the next page; `null` when the walk is done. */
938
+ nextPage: string | number | null;
939
+ /**
940
+ * An opaque token pinning every page of this walk to one logical view of the
941
+ * origin.
942
+ *
943
+ * **Omit it and you get `best-effort` coverage, not `complete`.** Without a
944
+ * snapshot, a page walk over live data is not a consistent read: fetch page 1,
945
+ * a row is inserted, everything shifts, and the row that was going to be on
946
+ * page 20 is now on page 19 — which you already passed. It is never seen. The
947
+ * walk still "succeeds", and the replica silently has a hole in it. Since
948
+ * nothing detects that, the honest response is to refuse to call the result
949
+ * complete, and to keep serving reads from the network.
950
+ */
951
+ snapshot?: string;
952
+ /**
953
+ * The cursor to begin the *delta* stream from once the walk finishes. Issued on
954
+ * the first page — i.e. as of the snapshot — so no change made during the walk
955
+ * can slip between "bootstrap ended" and "delta began".
956
+ */
957
+ deltaCursor?: string;
958
+ /** Total rows in scope, when the origin knows it. Drives progress reporting. */
959
+ total?: number;
960
+ }
961
+ /** One batch of incremental changes since a cursor. */
962
+ interface DeltaPage<RemoteRow> {
963
+ changed: RemoteRow[];
964
+ /**
965
+ * Primary keys the origin has deleted.
966
+ *
967
+ * This is the only way a REST replica learns about deletions. A plain paged GET
968
+ * returns survivors, and a row's *absence* from a response is ambiguous — it may
969
+ * have been deleted, or it may merely have shifted to another page. Guessing
970
+ * would eventually delete live data, so we never infer; the origin must say so.
971
+ */
972
+ deleted: RemoteKey[];
973
+ cursor: string;
974
+ hasMore: boolean;
975
+ }
976
+ /**
977
+ * Everything the coordinator needs to replicate one collection from one origin.
978
+ *
979
+ * @typeParam RemoteRow - the row shape the origin returns, before mapping.
980
+ * @typeParam T - the local document shape.
981
+ */
982
+ interface ReplicationSource<RemoteRow = unknown, T extends Document = Document> {
983
+ /** Bump when a custom source's behavior changes without changing its metadata. */
984
+ readonly configVersion?: string | number;
985
+ /** Stable identity for this origin. Part of the coverage key. */
986
+ readonly origin: string;
987
+ /** The local collection this source fills. */
988
+ readonly collection: string;
989
+ /**
990
+ * The authorization slice these rows belong to — a user, tenant, or store.
991
+ * Part of the coverage key, so one user's completeness never licenses another's
992
+ * reads. Use a constant for genuinely global data.
993
+ */
994
+ readonly scope: string;
995
+ /** Bump when {@link mapRow} starts producing a different shape. */
996
+ readonly projectionVersion: number;
997
+ /** Bump when the local schema changes in a way hydrated rows must match. */
998
+ readonly schemaVersion: number;
999
+ /** Fetch one page of the initial walk. */
1000
+ bootstrap(request: BootstrapRequest): Promise<BootstrapPage<RemoteRow>>;
1001
+ /** Fetch changes since `cursor`. Absent when the origin has no delta feed. */
1002
+ delta?(cursor: string): Promise<DeltaPage<RemoteRow>>;
1003
+ /**
1004
+ * Fetch exactly the rows a specific query needs, for the cold-start bridge.
1005
+ *
1006
+ * Optional. When absent, a query against an un-hydrated scope simply waits for
1007
+ * coverage rather than short-circuiting to the network.
1008
+ */
1009
+ fetchQuery?(query: BridgeQuery): Promise<RemoteRow[]>;
1010
+ /** The origin's primary key for a row. Must be stable across fetches. */
1011
+ keyOf(row: RemoteRow): RemoteKey;
1012
+ /**
1013
+ * Monotonic authoritative revision for stale-response protection. Strongly
1014
+ * recommended whenever bridge/bootstrap/delta requests may overlap.
1015
+ */
1016
+ revisionOf(row: RemoteRow): number;
1017
+ /** Shape a remote row into a local document (minus `_id`, which is derived). */
1018
+ mapRow(row: RemoteRow): Omit<T, '_id'>;
1019
+ }
1020
+ /**
1021
+ * A local query, handed to the bridge so it can ask the origin for the same rows.
1022
+ *
1023
+ * Deliberately loose: every REST API spells pagination and filtering differently,
1024
+ * so translating this into a query string is the source's job, not ours.
1025
+ */
1026
+ interface BridgeQuery {
1027
+ filter?: Record<string, unknown>;
1028
+ sort?: Record<string, 1 | -1>;
1029
+ page?: number;
1030
+ limit?: number;
1031
+ }
1032
+
1033
+ /**
1034
+ * The replication coordinator — all orchestration, no wire format.
1035
+ *
1036
+ * Owns: the bootstrap walk, resume-after-crash, delta refresh, the cold-start
1037
+ * bridge, batching, yielding, coverage transitions, and in-flight dedup. The
1038
+ * {@link ReplicationSource} it drives owns only wire translation.
1039
+ *
1040
+ * ## The two mechanisms are one mechanism
1041
+ *
1042
+ * "Fetch the page the user is looking at" and "import the whole catalog in the
1043
+ * background" look like separate features. They are the same primitive with two
1044
+ * schedulers: *fetch rows → upsert them by derived id*. Because both write the
1045
+ * **same rows under the same ids**, they compose for free — a bridged fetch is not
1046
+ * a throwaway cache entry, it is a down payment on the replica, and when the walk
1047
+ * later reaches those rows it overwrites them in place instead of duplicating
1048
+ * them. Nothing has to reconcile the two.
1049
+ *
1050
+ * The one thing they do *not* share is coverage. A bridge fetch must never advance
1051
+ * the bootstrap cursor, because it did not come from the walk's snapshot and
1052
+ * proves nothing about completeness. Trading a little duplicate network for a
1053
+ * trustworthy completeness proof is the right side of that bargain.
1054
+ */
1055
+
1056
+ interface CoordinatorOptions<T extends Document = Document> {
1057
+ /** Rows per bootstrap page. Larger = fewer commits, longer stalls. */
1058
+ pageSize?: number;
1059
+ /**
1060
+ * Called between pages so the walk yields. Defaults to a macrotask.
1061
+ *
1062
+ * This matters more than it looks. Live queries re-run on a 300 ms poll, and on
1063
+ * React Native every write is *synchronous on the JS thread* — a tight bootstrap
1064
+ * loop starves both, and the UI freezes for the duration of the import.
1065
+ */
1066
+ yieldFn?: () => Promise<void>;
1067
+ /** Fired after each committed page, for progress UI. */
1068
+ onProgress?: (state: CoverageState) => void;
1069
+ /** Collection schema/migration options registered by the host application. */
1070
+ collectionOptions?: CollectionOptions<T>;
1071
+ }
1072
+ declare const REPLICA_SCOPE_FIELD = "_replica_scope";
1073
+ declare const REPLICA_REVISION_FIELD = "_remote_rev";
1074
+ interface BridgeResult {
1075
+ count: number;
1076
+ ids: string[];
1077
+ }
1078
+ declare class ReplicationCoordinator<RemoteRow, T extends Document> {
1079
+ private readonly db;
1080
+ private readonly source;
1081
+ private readonly coverage;
1082
+ private readonly key;
1083
+ private readonly pageSize;
1084
+ private readonly yieldFn;
1085
+ private readonly onProgress?;
1086
+ private readonly collectionOptions?;
1087
+ /**
1088
+ * In-flight passes, keyed by intent. Two components mounting the same query must
1089
+ * fire one request, and the background walk must not race the bridge for the
1090
+ * same rows — both join the existing promise instead.
1091
+ */
1092
+ private readonly inflight;
1093
+ constructor(db: TalaDB, source: ReplicationSource<RemoteRow, T>, options?: CoordinatorOptions<T>);
1094
+ get replicaScope(): string;
1095
+ private get identityNamespace();
1096
+ getCoverage(): Promise<CoverageState>;
1097
+ /** Whether a purely local read is authorized right now. */
1098
+ isReady(): Promise<boolean>;
1099
+ /** Dedup by intent: identical concurrent work joins rather than duplicating. */
1100
+ private dedup;
1101
+ /**
1102
+ * Write a batch of remote rows into the local collection.
1103
+ *
1104
+ * One commit for the whole batch, ids derived from the origin's primary key, and
1105
+ * `origin: 'remote'` so the rows can never replicate back out at the origin they
1106
+ * came from. This is the *only* write path in the coordinator — bootstrap, delta
1107
+ * and bridge all funnel through it, which is precisely why they converge instead
1108
+ * of conflicting.
1109
+ */
1110
+ private applyRows;
1111
+ /**
1112
+ * Hydrate the scope: walk the origin page by page until the whole collection is
1113
+ * local, then mark it complete.
1114
+ *
1115
+ * Resumable and idempotent. If the walk is interrupted — a reload, a crash, a
1116
+ * dead network — the next call picks up from the last committed page, and
1117
+ * re-applying a page it already wrote is a no-op because the ids are derived.
1118
+ */
1119
+ hydrate(): Promise<CoverageState>;
1120
+ private runHydrate;
1121
+ /**
1122
+ * Apply incremental changes since the stored cursor.
1123
+ *
1124
+ * Deletions are applied by mapping the origin's primary keys through the same
1125
+ * `deriveDocId`, and are written with `origin: 'remote'` so they leave no
1126
+ * tombstone — the origin already knows it deleted these, and a tombstone would
1127
+ * push its own deletion back at it.
1128
+ */
1129
+ refresh(): Promise<CoverageState>;
1130
+ private runRefresh;
1131
+ /**
1132
+ * Cold-start bridge: fetch exactly the rows one query needs, right now.
1133
+ *
1134
+ * Needed because a SPA or React Native app has no server render to paint behind
1135
+ * while the replica fills. The rows land in the same collection under the same
1136
+ * derived ids as the walk's, so this is not a cache — it is the replica, arriving
1137
+ * early.
1138
+ *
1139
+ * **Does not advance coverage.** These rows did not come from the bootstrap
1140
+ * snapshot and prove nothing about completeness; treating them as progress would
1141
+ * let a page-1 fetch masquerade as a hydrated catalog.
1142
+ */
1143
+ bridge(query: BridgeQuery): Promise<BridgeResult>;
1144
+ /** Drop coverage and force a fresh bootstrap. Local rows are left alone. */
1145
+ reset(): Promise<void>;
1146
+ }
1147
+
1148
+ /**
1149
+ * A {@link ReplicationSource} for an ordinary paged JSON API.
1150
+ *
1151
+ * This is the adoption path: point it at `GET /api/products?page=1&limit=500` and
1152
+ * a team on Express + Postgres gets a local replica without rewriting their API to
1153
+ * speak TalaDB's sync contract. Everything here is wire translation — the
1154
+ * coordinator owns the walk, the coverage, and the retries.
1155
+ *
1156
+ * ## What the origin has to provide, and what happens when it doesn't
1157
+ *
1158
+ * | Feature | Endpoint | Without it |
1159
+ * |---|---|---|
1160
+ * | Paged list | `?page=&limit=` | Nothing works. Required. |
1161
+ * | Snapshot token | `snapshot` in the response | Coverage caps at `best-effort`; reads keep hitting the network |
1162
+ * | Delta feed | `?since=<cursor>` | No incremental refresh, and **deletions never propagate** |
1163
+ *
1164
+ * The snapshot and the delta feed are each about twenty minutes of Express work
1165
+ * (a monotonic `updated_at`/revision column, a soft-delete table, and a
1166
+ * `rev <= snapshotRev` predicate). They are worth it: without a snapshot the
1167
+ * replica can never be trusted for a local-only read, which is the entire point.
1168
+ */
1169
+
1170
+ interface RestSourceOptions<RemoteRow, T extends Document> {
1171
+ /** Base URL, e.g. `/api/products`. */
1172
+ endpoint: string;
1173
+ /** The local collection to fill. */
1174
+ collection: string;
1175
+ /** Stable identity for the origin. Defaults to `endpoint`. */
1176
+ origin?: string;
1177
+ /**
1178
+ * The authorization slice these rows belong to — a user id, tenant, or store.
1179
+ * Part of the coverage key, so one user's completeness never licenses another
1180
+ * user's reads. Defaults to `'global'`; **set it for anything user-scoped.**
1181
+ */
1182
+ scope?: string;
1183
+ /** Bump when {@link mapRow} starts producing a different shape. Default 1. */
1184
+ projectionVersion?: number;
1185
+ /** Bump when the local schema changes. Default 1. */
1186
+ schemaVersion?: number;
1187
+ /** Field on the remote row holding its primary key. Default `'id'`. */
1188
+ key?: string;
1189
+ /** Field/callback yielding a monotonic numeric row revision. Default `'rev'`. */
1190
+ revision?: string | ((row: RemoteRow) => number | undefined);
1191
+ /** Shape a remote row into a local document. Default: identity, minus `_id`. */
1192
+ mapRow?: (row: RemoteRow) => Omit<T, '_id'>;
1193
+ /** Per-request headers, resolved **at send time** so a refreshed token is used. */
1194
+ getAuth?: () => Promise<Record<string, string>> | Record<string, string>;
1195
+ /** `fetch` implementation. Defaults to the global. */
1196
+ fetch?: typeof fetch;
1197
+ /** Sub-paths appended to `endpoint`. */
1198
+ paths?: {
1199
+ bootstrap?: string;
1200
+ delta?: string;
1201
+ };
1202
+ /** Enable delta polling. Defaults to true only when `paths.delta` is set. */
1203
+ delta?: boolean;
1204
+ /** Meaning of the fallback `page` parameter when no next token is returned. */
1205
+ pagination?: 'page' | 'offset';
1206
+ /** Translate a local query into this API's query-string conventions. */
1207
+ toParams?: (query: BridgeQuery) => Record<string, string>;
1208
+ /** Pull the row array out of a response whose envelope we don't recognize. */
1209
+ parse?: (body: unknown) => unknown[];
1210
+ }
1211
+ declare function createRestSource<RemoteRow = Record<string, unknown>, T extends Document = Document>(options: RestSourceOptions<RemoteRow, T>): ReplicationSource<RemoteRow, T>;
1212
+
576
1213
  /**
577
1214
  * Thrown when a document fails schema validation on `insert` or `insertMany`.
578
1215
  * The `cause` property holds the original error thrown by the schema library.
@@ -684,4 +1321,4 @@ interface OpenDBOptions {
684
1321
  */
685
1322
  declare function openDB(dbName?: string, options?: OpenDBOptions): Promise<TalaDB>;
686
1323
 
687
- export { type AggregatePipeline, type AggregateStage, type Collection, type CollectionIndexInfo, type CollectionOptions, type Document, type DurabilityConfig, type Filter, HttpSyncAdapter, type Migration, type OpenDBOptions, type Schema, type SerializedChangeset, type SyncAdapter, type SyncConfig, type SyncDirection, type SyncOptions, type SyncResult, type TalaDB, type TalaDbConfig, TalaDbValidationError, type Update, type Value, type VectorIndexOptions, type VectorMetric, type VectorSearchResult, applySchema, openDB, runMigrations };
1324
+ export { type AggregatePipeline, type AggregateStage, type BootstrapPage, type BootstrapRequest, type BridgeQuery, type BridgeResult, COVERAGE_COLLECTION, type Collection, type CollectionIndexInfo, type CollectionOptions, type CoordinatorOptions, type CoverageKey, type CoverageState, CoverageStore, type CursorSyncAdapter, type DeltaPage, type Document, type DurabilityConfig, type Filter, HttpSyncAdapter, type Migration, type OpenDBOptions, type PullResult, REPLICA_REVISION_FIELD, REPLICA_SCOPE_FIELD, type RemoteKey, ReplicationCoordinator, type ReplicationSource, type RestSourceOptions, type Schema, type SerializedChangeset, type SyncAdapter, type SyncConfig, type SyncDirection, type SyncOptions, type SyncResult, type TalaDB, type TalaDbConfig, TalaDbValidationError, type Update, type Value, type VectorIndexOptions, type VectorMetric, type VectorSearchResult, type WriteOrigin, applySchema, coverageKey, createRestSource, deriveDocId, isAuthoritative, openDB, progress, rowsApplied, runMigrations };