taladb 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Local-first document database for React, React Native, and Node.js — powered by a Rust/WASM core with zero GC pauses.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/taladb)](https://www.npmjs.com/package/taladb)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-orange.svg)](LICENSE)
6
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-orange.svg)](LICENSE)
7
7
 
8
8
  ## What is TalaDB?
9
9
 
@@ -147,4 +147,4 @@ await runMigrations(db, [
147
147
 
148
148
  ## License
149
149
 
150
- MIT © [ThinkGrid Labs](https://github.com/thinkgrid-labs)
150
+ Apache 2.0 © [ThinkGrid Labs](https://github.com/thinkgrid-labs)
@@ -659,18 +659,52 @@ function deepEqual(a, b) {
659
659
  );
660
660
  }
661
661
  function applySchema(col, options) {
662
- const { schema, validateOnRead = false, migrateDocument, syncSchema, persistMigrations = false } = options;
662
+ const {
663
+ schema,
664
+ validateOnRead = false,
665
+ migrateDocument,
666
+ downgradeDocument,
667
+ syncSchema,
668
+ persistMigrations = false,
669
+ allowFieldRemoval = false,
670
+ retiredFields = []
671
+ } = options;
663
672
  const targetVersion = syncSchema?.version ?? 0;
664
673
  if (migrateDocument && targetVersion < 1) {
665
674
  throw new Error("CollectionOptions.migrateDocument requires syncSchema.version (the migration target)");
666
675
  }
676
+ if (downgradeDocument && targetVersion < 1) {
677
+ throw new Error("CollectionOptions.downgradeDocument requires syncSchema.version (the shape this build reads)");
678
+ }
667
679
  if (syncSchema && targetVersion < 1 && (syncSchema.renames || syncSchema.defaults)) {
668
680
  throw new Error(
669
681
  "CollectionOptions.syncSchema.renames/defaults require syncSchema.version >= 1 \u2014 without a version the import migration step never runs and documents missing the renamed/defaulted fields are quarantined instead of upgraded"
670
682
  );
671
683
  }
672
684
  const stampVersion = targetVersion > 0;
673
- if (!schema && !migrateDocument && !stampVersion) return col;
685
+ if (!schema && !migrateDocument && !downgradeDocument && !stampVersion) return col;
686
+ const retired = new Set(retiredFields);
687
+ const engineOwned = /* @__PURE__ */ new Set([
688
+ "_id",
689
+ "_v",
690
+ "_changed_at",
691
+ "_remote",
692
+ "_remote_rev",
693
+ "_replica_scope"
694
+ ]);
695
+ const downcastViews = /* @__PURE__ */ new WeakSet();
696
+ function preserveFields(original, next, preserveUnknown, preserveVersion = true) {
697
+ let out = null;
698
+ for (const k of Object.keys(original)) {
699
+ const ownedField = engineOwned.has(k) && (k !== "_v" || preserveVersion);
700
+ const mustRestore = ownedField || preserveUnknown && !retired.has(k) && !(k in next);
701
+ if (!mustRestore) continue;
702
+ if (!ownedField && k in next) continue;
703
+ out ?? (out = { ...next });
704
+ out[k] = original[k];
705
+ }
706
+ return out ?? next;
707
+ }
674
708
  function parseWrite(doc, label) {
675
709
  try {
676
710
  return schema.parse(doc);
@@ -678,6 +712,43 @@ function applySchema(col, options) {
678
712
  throw new TalaDbValidationError(err, label);
679
713
  }
680
714
  }
715
+ function assertWritableDocument(doc, label) {
716
+ if (doc && typeof doc === "object" && downcastViews.has(doc)) {
717
+ throw new Error(`${label}: a downgradeDocument result is a read-only compatibility view`);
718
+ }
719
+ const version = doc?._v;
720
+ if (targetVersion > 0 && typeof version === "number" && version > targetVersion) {
721
+ throw new Error(
722
+ `${label}: this client supports schema v${targetVersion}, but the document is v${version}`
723
+ );
724
+ }
725
+ }
726
+ function writableFilter(filter) {
727
+ if (targetVersion < 1) return filter;
728
+ return {
729
+ $and: [
730
+ filter,
731
+ {
732
+ $or: [
733
+ { _v: { $exists: false } },
734
+ { _v: { $lte: targetVersion } }
735
+ ]
736
+ }
737
+ ]
738
+ };
739
+ }
740
+ function assertSafeUpdate(update) {
741
+ const record = update;
742
+ for (const op of ["$set", "$unset", "$inc", "$push", "$pull"]) {
743
+ const fields = record[op];
744
+ if (!fields) continue;
745
+ for (const field of Object.keys(fields)) {
746
+ if (engineOwned.has(field)) {
747
+ throw new Error(`update cannot modify engine-owned field '${field}'`);
748
+ }
749
+ }
750
+ }
751
+ }
681
752
  function stamp(doc) {
682
753
  if (!stampVersion || doc._v !== void 0) return doc;
683
754
  return { ...doc, _v: targetVersion };
@@ -693,50 +764,86 @@ function applySchema(col, options) {
693
764
  if (k === "_id") continue;
694
765
  if (!deepEqual(migrated[k], original[k])) $set[k] = migrated[k];
695
766
  }
696
- for (const k of Object.keys(original)) {
697
- if (k !== "_id" && !(k in migrated)) $unset[k] = true;
767
+ if (allowFieldRemoval || retired.size > 0) {
768
+ for (const k of Object.keys(original)) {
769
+ if (k !== "_id" && !(k in migrated) && (allowFieldRemoval || retired.has(k))) $unset[k] = true;
770
+ }
698
771
  }
699
772
  const update = {};
700
773
  if (Object.keys($set).length) update.$set = $set;
701
774
  if (Object.keys($unset).length) update.$unset = $unset;
702
775
  return Object.keys(update).length ? update : null;
703
776
  }
704
- function migrateRead(doc) {
705
- if (!migrateDocument) return doc;
777
+ function normalizeRead(doc) {
706
778
  const fromVersion = typeof doc._v === "number" ? doc._v : 0;
707
- if (fromVersion >= targetVersion) return doc;
708
- return { ...migrateDocument(doc, fromVersion), _v: targetVersion };
779
+ if (migrateDocument && fromVersion < targetVersion) {
780
+ const up = { ...migrateDocument(doc, fromVersion), _v: targetVersion };
781
+ return {
782
+ // The migration owns the version transition, while every other
783
+ // engine-owned field continues to come from the stored document.
784
+ value: allowFieldRemoval ? preserveFields(doc, up, false, false) : preserveFields(doc, up, true, false),
785
+ persistable: true
786
+ };
787
+ }
788
+ if (downgradeDocument && fromVersion > targetVersion) {
789
+ const projected = {
790
+ ...downgradeDocument(doc, fromVersion),
791
+ ...doc._id !== void 0 ? { _id: doc._id } : {},
792
+ _v: fromVersion
793
+ };
794
+ downcastViews.add(projected);
795
+ return { value: projected, persistable: false };
796
+ }
797
+ return { value: doc, persistable: true };
709
798
  }
710
799
  function validateRead(doc) {
711
800
  if (!validateOnRead || !schema) return doc;
712
801
  try {
713
- return schema.parse(doc);
802
+ const parsed = schema.parse(doc);
803
+ const fromVersion = typeof doc._v === "number" ? doc._v : 0;
804
+ return preserveFields(doc, parsed, targetVersion > 0 && fromVersion > targetVersion);
714
805
  } catch (err) {
715
806
  throw new TalaDbValidationError(err, "read");
716
807
  }
717
808
  }
718
- async function persistAll(originals, migrated) {
809
+ async function persistAll(originals, normalized) {
719
810
  if (!persistMigrations) return;
720
811
  for (let i = 0; i < originals.length; i++) {
721
812
  const original = originals[i];
722
- if (migrated[i] === original || typeof original._id !== "string") continue;
723
- const update = diffUpdate(original, migrated[i]);
813
+ if (!normalized[i].persistable) continue;
814
+ const migrated = normalized[i].value;
815
+ if (migrated === original || typeof original._id !== "string") continue;
816
+ const update = diffUpdate(original, migrated);
724
817
  if (!update) continue;
725
818
  try {
726
- await col.updateOne({ _id: original._id }, update);
819
+ const guards = [{ _id: original._id }];
820
+ if (original._v === void 0) guards.push({ _v: { $exists: false } });
821
+ else guards.push({ _v: original._v });
822
+ if (original._changed_at !== void 0) {
823
+ guards.push({ _changed_at: original._changed_at });
824
+ }
825
+ await col.updateOne({ $and: guards }, update);
727
826
  } catch {
728
827
  }
729
828
  }
730
829
  }
731
- const wrapReads = Boolean(migrateDocument) || validateOnRead && Boolean(schema);
830
+ const wrapReads = Boolean(migrateDocument) || Boolean(downgradeDocument) || validateOnRead && Boolean(schema);
732
831
  const wrapWrites = Boolean(schema) || stampVersion;
832
+ function pipelinePreservesDocuments(pipeline) {
833
+ return pipeline.every((stage) => !("$group" in stage) && !("$project" in stage));
834
+ }
835
+ function normalizeViewRows(docs) {
836
+ return docs.map((doc) => validateRead(normalizeRead(doc).value));
837
+ }
733
838
  return {
734
839
  ...col,
735
840
  insert: wrapWrites ? async (doc) => {
841
+ assertWritableDocument(doc, "insert");
736
842
  if (schema) parseWrite(doc, "insert");
737
843
  return col.insert(stamp(doc));
738
844
  } : col.insert.bind(col),
739
845
  insertMany: wrapWrites ? async (docs) => {
846
+ docs.forEach((doc, i) => assertWritableDocument(doc, `insertMany[${i}]`));
740
847
  if (schema) docs.forEach((doc, i) => parseWrite(doc, `insertMany[${i}]`));
741
848
  return col.insertMany(docs.map(stamp));
742
849
  } : col.insertMany.bind(col),
@@ -745,6 +852,14 @@ function applySchema(col, options) {
745
852
  // runtime schema check have to be the same seam, or a malformed server
746
853
  // response walks straight into a typed collection.
747
854
  replaceManyWithIds: wrapWrites ? async (docs, origin) => {
855
+ if (origin !== "remote") {
856
+ docs.forEach((doc, i) => assertWritableDocument(doc, `replaceManyWithIds[${i}]`));
857
+ if (targetVersion > 0) {
858
+ throw new Error(
859
+ "local replaceManyWithIds is disabled on versioned collections; use updateOne/updateMany so schema-version guards are atomic"
860
+ );
861
+ }
862
+ }
748
863
  if (schema) docs.forEach((doc, i) => {
749
864
  const { _replica_scope, _remote_rev, ...schemaDoc } = doc;
750
865
  void _replica_scope;
@@ -753,38 +868,75 @@ function applySchema(col, options) {
753
868
  });
754
869
  return col.replaceManyWithIds(docs.map((d) => stampDoc(d)), origin);
755
870
  } : col.replaceManyWithIds.bind(col),
871
+ deleteManyWithIds: stampVersion ? async (ids, origin) => {
872
+ if (origin !== "remote") {
873
+ throw new Error(
874
+ "local deleteManyWithIds is disabled on versioned collections; use deleteOne/deleteMany so schema-version guards are atomic"
875
+ );
876
+ }
877
+ return col.deleteManyWithIds(ids, origin);
878
+ } : col.deleteManyWithIds.bind(col),
879
+ updateOne: wrapWrites ? async (filter, update) => {
880
+ assertSafeUpdate(update);
881
+ return col.updateOne(writableFilter(filter), update);
882
+ } : col.updateOne.bind(col),
883
+ updateMany: wrapWrites ? async (filter, update) => {
884
+ assertSafeUpdate(update);
885
+ return col.updateMany(writableFilter(filter), update);
886
+ } : col.updateMany.bind(col),
887
+ deleteOne: stampVersion ? (filter) => col.deleteOne(writableFilter(filter)) : col.deleteOne.bind(col),
888
+ deleteMany: stampVersion ? (filter) => col.deleteMany(writableFilter(filter)) : col.deleteMany.bind(col),
756
889
  find: wrapReads ? async (filter) => {
757
890
  const docs = await col.find(filter);
758
- const migrated = docs.map(migrateRead);
759
- await persistAll(docs, migrated);
760
- return migrated.map(validateRead);
891
+ const normalized = docs.map(normalizeRead);
892
+ await persistAll(docs, normalized);
893
+ return normalized.map((n) => validateRead(n.value));
761
894
  } : col.find.bind(col),
762
895
  findOne: wrapReads ? async (filter) => {
763
896
  const doc = await col.findOne(filter);
764
897
  if (doc === null) return null;
765
- const migrated = migrateRead(doc);
766
- await persistAll([doc], [migrated]);
767
- return validateRead(migrated);
898
+ const normalized = normalizeRead(doc);
899
+ await persistAll([doc], [normalized]);
900
+ return validateRead(normalized.value);
768
901
  } : col.findOne.bind(col),
902
+ aggregate: wrapReads ? async (pipeline) => {
903
+ const docs = await col.aggregate(pipeline);
904
+ return pipelinePreservesDocuments(pipeline) ? normalizeViewRows(docs) : docs;
905
+ } : col.aggregate.bind(col),
769
906
  // Live queries feed every @taladb/react hook (useFind, useFindOne,
770
907
  // useQueries). Leaving them unwrapped meant React components received the
771
908
  // un-migrated shape while a direct find() returned the migrated one.
772
909
  subscribe: wrapReads ? (filter, callback, onError) => col.subscribe(
773
910
  filter,
774
911
  (docs) => {
775
- const migrated = docs.map(migrateRead);
912
+ const normalized = docs.map(normalizeRead);
776
913
  let out;
777
914
  try {
778
- out = migrated.map(validateRead);
915
+ out = normalized.map((n) => validateRead(n.value));
779
916
  } catch (err) {
780
917
  onError?.(err);
781
918
  return;
782
919
  }
783
920
  callback(out);
784
- void persistAll(docs, migrated);
921
+ void persistAll(docs, normalized);
785
922
  },
786
923
  onError
787
- ) : col.subscribe.bind(col)
924
+ ) : col.subscribe.bind(col),
925
+ subscribeAggregate: wrapReads ? (pipeline, callback, onError) => col.subscribeAggregate(
926
+ pipeline,
927
+ (docs) => {
928
+ if (!pipelinePreservesDocuments(pipeline)) {
929
+ callback(docs);
930
+ return;
931
+ }
932
+ try {
933
+ callback(normalizeViewRows(docs));
934
+ } catch (error) {
935
+ onError?.(error);
936
+ }
937
+ },
938
+ onError
939
+ ) : col.subscribeAggregate.bind(col)
788
940
  };
789
941
  }
790
942
  function detectPlatform() {
@@ -994,6 +1146,30 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
994
1146
  });
995
1147
  return JSON.parse(json);
996
1148
  },
1149
+ searchText: async (field, query, topK, filter, options) => {
1150
+ const json = await proxy.send("searchText", {
1151
+ collection: name,
1152
+ field,
1153
+ query,
1154
+ topK,
1155
+ filterJson: filter ? JSON.stringify(filter) : "null",
1156
+ optionsJson: options ? JSON.stringify(options) : "null"
1157
+ });
1158
+ return JSON.parse(json);
1159
+ },
1160
+ hybridSearch: async (text, vector, topK, filter, options) => {
1161
+ const json = await proxy.send("hybridSearch", {
1162
+ collection: name,
1163
+ textField: text.textField,
1164
+ text: text.text,
1165
+ vectorField: vector.vectorField,
1166
+ vectorJson: JSON.stringify(vector.vector),
1167
+ topK,
1168
+ filterJson: filter ? JSON.stringify(filter) : "null",
1169
+ optionsJson: options ? JSON.stringify(options) : "null"
1170
+ });
1171
+ return JSON.parse(json);
1172
+ },
997
1173
  subscribe: (filter, callback, onError) => nudgedPoller(
998
1174
  () => proxy.send("find", {
999
1175
  collection: name,
@@ -1138,6 +1314,12 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
1138
1314
  const raw = await col.findNearest(field, vector, topK, filter ?? null);
1139
1315
  return raw;
1140
1316
  },
1317
+ searchText: async (field, query, topK, filter, options) => {
1318
+ return col.searchText(field, query, topK, filter ?? null, options ?? null);
1319
+ },
1320
+ hybridSearch: async (text, vector, topK, filter, options) => {
1321
+ return col.hybridSearch(text.textField, text.text, vector.vectorField, vector.vector, topK, filter ?? null, options ?? null);
1322
+ },
1141
1323
  subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError),
1142
1324
  subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => wrapped.aggregate(pipeline), callback, onError)
1143
1325
  };
@@ -1220,6 +1402,18 @@ async function createNativeDB(_dbName, migrations) {
1220
1402
  const raw = native.findNearest(name, field, vector, topK, filter ?? null);
1221
1403
  return raw;
1222
1404
  },
1405
+ searchText: async (field, query, topK, filter, options) => {
1406
+ if (!native.searchText) {
1407
+ throw new Error("searchText requires @taladb/react-native \u2265 0.10 \u2014 rebuild the native module");
1408
+ }
1409
+ return native.searchText(name, field, query, topK, filter ?? null, options ?? null);
1410
+ },
1411
+ hybridSearch: async (text, vector, topK, filter, options) => {
1412
+ if (!native.hybridSearch) {
1413
+ throw new Error("hybridSearch requires @taladb/react-native \u2265 0.10 \u2014 rebuild the native module");
1414
+ }
1415
+ return native.hybridSearch(name, text.textField, text.text, vector.vectorField, vector.vector, topK, filter ?? null, options ?? null);
1416
+ },
1223
1417
  subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError),
1224
1418
  subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => native.aggregate(name, pipeline), callback, onError)
1225
1419
  };
package/dist/index.d.mts CHANGED
@@ -36,6 +36,67 @@ interface VectorSearchResult<T extends Document = Document> {
36
36
  */
37
37
  score: number;
38
38
  }
39
+ interface TextSearchResult<T extends Document = Document> {
40
+ /** The matched document. */
41
+ document: T;
42
+ /**
43
+ * BM25 relevance score — higher means more relevant. Unbounded above, and
44
+ * only meaningful for ordering within a single query's result set.
45
+ */
46
+ score: number;
47
+ }
48
+ /** Tuning for `searchText`'s BM25 ranking. */
49
+ interface TextSearchOptions {
50
+ /**
51
+ * Term-frequency saturation (BM25 `k1`, default `1.2`). Higher values let a
52
+ * repeated term keep adding relevance for longer.
53
+ */
54
+ k1?: number;
55
+ /**
56
+ * Length normalisation (BM25 `b`, default `0.75`). `0` ignores document
57
+ * length; `1` normalises fully by length relative to the corpus average.
58
+ */
59
+ b?: number;
60
+ }
61
+ interface HybridSearchResult<T extends Document = Document> {
62
+ /** The matched document. */
63
+ document: T;
64
+ /**
65
+ * Fused reciprocal-rank-fusion score. Small by construction and meaningful
66
+ * only as an ordering within one result set — never a similarity or a
67
+ * confidence.
68
+ */
69
+ score: number;
70
+ /**
71
+ * Zero-based position in the text ranking, or `null` if the text retriever
72
+ * did not return this document.
73
+ */
74
+ textRank: number | null;
75
+ /**
76
+ * Zero-based position in the vector ranking, or `null` if the vector
77
+ * retriever did not return this document.
78
+ */
79
+ vectorRank: number | null;
80
+ }
81
+ /** Tuning for `hybridSearch`'s fusion and per-retriever scoring. */
82
+ interface HybridSearchOptions extends TextSearchOptions {
83
+ /**
84
+ * Reciprocal rank fusion smoothing constant (default `60`). Larger values
85
+ * flatten the advantage of the very top ranks.
86
+ */
87
+ rrfK?: number;
88
+ /** Relative weight of the text ranking (default `1`). Set `0` to disable it. */
89
+ textWeight?: number;
90
+ /** Relative weight of the vector ranking (default `1`). Set `0` to disable it. */
91
+ vectorWeight?: number;
92
+ /**
93
+ * How many candidates to pull from each retriever before fusing
94
+ * (default `max(topK * 4, 20)`). Raise it for better recall at more cost;
95
+ * fusing only `topK` from each side drops documents that rank just outside
96
+ * one retriever but high in the other.
97
+ */
98
+ candidates?: number;
99
+ }
39
100
  type Value = null | boolean | number | string | Uint8Array | Value[] | {
40
101
  [key: string]: Value;
41
102
  };
@@ -221,12 +282,42 @@ interface CollectionOptions<T extends Document = Document> {
221
282
  * });
222
283
  */
223
284
  migrateDocument?: (doc: T, fromVersion: number) => T;
285
+ /**
286
+ * Lazy, read-time **downcast** — the mirror of {@link migrateDocument}, for a
287
+ * document written by a *newer* peer. When set, every document returned by
288
+ * `find` / `findOne` whose `_v` is **above** `syncSchema.version` is passed
289
+ * through `downgradeDocument(doc, fromVersion)` and projected into the shape
290
+ * this build understands, so application code on an old client sees a shape
291
+ * it can actually read instead of an unexpected future one.
292
+ *
293
+ * Requires `syncSchema.version`. Must be pure and deterministic.
294
+ *
295
+ * **The projection is view-only and is never persisted**, regardless of
296
+ * {@link persistMigrations}. The stored document keeps its original `_v` and
297
+ * its newer fields intact, because this replica must continue to replicate
298
+ * that document faithfully to other peers — an old client is a *reader* of a
299
+ * newer shape, never its editor. For the same reason the returned document
300
+ * keeps its original (higher) `_v`: it is a projection of a v-N document, not
301
+ * a v-M one, and writing it back wholesale would tell the fleet otherwise.
302
+ *
303
+ * @example
304
+ * // This build understands v1. A v2 peer split `name` into first/last.
305
+ * const users = db.collection<User>('users', {
306
+ * syncSchema: { version: 1 },
307
+ * downgradeDocument: (doc) => ({ ...doc, name: `${doc.first} ${doc.last}` }),
308
+ * });
309
+ */
310
+ downgradeDocument?: (doc: Readonly<T>, fromVersion: number) => T;
224
311
  /**
225
312
  * When `true`, a document upgraded by {@link migrateDocument} on read is
226
- * **written back** to storage (a best-effort `updateOne` computing the
227
- * `$set`/`$unset` diff) so the migration becomes permanent — after which
228
- * filters and indexes on the new shape match it. Default `false` (the
229
- * migrated shape is returned but not persisted).
313
+ * **written back** to storage (a best-effort `updateOne` computing a `$set`
314
+ * diff) so the migration becomes permanent — after which filters and indexes
315
+ * on the new shape match it. Default `false` (the migrated shape is returned
316
+ * but not persisted).
317
+ *
318
+ * The write-back is **additive-only** except for fields explicitly listed in
319
+ * {@link retiredFields}. A field present in storage but absent from the
320
+ * migrated document is otherwise left alone rather than `$unset`.
230
321
  *
231
322
  * Trade-offs: reads that encounter un-migrated documents now issue writes
232
323
  * (which fire live-query and sync-hook notifications like any other write);
@@ -234,6 +325,35 @@ interface CollectionOptions<T extends Document = Document> {
234
325
  * one-shot eager rewrite instead, prefer `openDB({ migrations })`.
235
326
  */
236
327
  persistMigrations?: boolean;
328
+ /**
329
+ * Fields an upcast is explicitly allowed to remove during persist-on-read.
330
+ * Prefer this precise list to {@link allowFieldRemoval}: omissions of any
331
+ * other field remain additive and are preserved.
332
+ */
333
+ retiredFields?: (keyof T & string)[];
334
+ /**
335
+ * How to read a field that {@link migrateDocument} left out of its output:
336
+ * as an intentional removal (`true`), or as a field the migration simply
337
+ * never heard of (`false`, the default).
338
+ *
339
+ * Default `false` — omitted fields are **preserved**: kept on the document
340
+ * returned to your code, and left in storage by the {@link persistMigrations}
341
+ * write-back rather than `$unset`.
342
+ *
343
+ * This default exists because on a synced collection the two cases are
344
+ * indistinguishable from the migration's output, and guessing "removal" is
345
+ * the destructive guess. A migration written today cannot mention a field a
346
+ * *newer* peer will add tomorrow, so an innocent `(doc) => ({ id, name })`
347
+ * becomes a deletion of a field its author never heard of — and under
348
+ * whole-document LWW that deletion replicates to the whole fleet. An old
349
+ * replica has to stay a faithful carrier of shapes it does not understand.
350
+ *
351
+ * Set `true` only on a collection that never syncs, or during a deliberate
352
+ * add → backfill → dual-read → **retire** rollout, where you already know the
353
+ * whole fleet has stopped writing the field.
354
+ */
355
+ /** @deprecated Prefer {@link retiredFields}; this treats every omission as removal. */
356
+ allowFieldRemoval?: boolean;
237
357
  }
238
358
  /** A single MongoDB-style aggregation stage. */
239
359
  type AggregateStage<T extends Document = Document> = {
@@ -351,6 +471,44 @@ interface Collection<T extends Document = Document> {
351
471
  createFtsIndex(field: keyof Omit<T, '_id'> & string): Promise<void>;
352
472
  /** Drop a full-text search index. */
353
473
  dropFtsIndex(field: keyof Omit<T, '_id'> & string): Promise<void>;
474
+ /**
475
+ * Rank documents against a free-text `query` using BM25, most relevant first.
476
+ *
477
+ * Unlike the `$contains` filter, which requires **every** token to be
478
+ * present, this uses OR semantics — a document that matches more of the
479
+ * query simply scores higher. Requires an FTS index on `field`.
480
+ *
481
+ * @example
482
+ * const hits = await articles.searchText('body', 'reset my password', 5);
483
+ * // hits: Array<{ document: Article, score: number }>
484
+ */
485
+ searchText(field: keyof Omit<T, '_id'> & string, query: string, topK: number, filter?: Filter<T>, options?: TextSearchOptions): Promise<TextSearchResult<T>[]>;
486
+ /**
487
+ * Hybrid retrieval: rank by keyword relevance (BM25) **and** vector
488
+ * similarity, then fuse the two rankings with reciprocal rank fusion.
489
+ *
490
+ * The two retrievers fail differently — keyword search misses paraphrases,
491
+ * vector search misses exact identifiers and rare proper nouns — so fusing
492
+ * them recovers both. A document both retrievers rank well outranks one that
493
+ * only a single retriever found. Requires an FTS index on `textField` and a
494
+ * vector index on `vectorField`.
495
+ *
496
+ * The optional `filter` is applied to both retrievers before ranking.
497
+ *
498
+ * @example
499
+ * const hits = await articles.hybridSearch(
500
+ * { textField: 'body', text: 'reset my password' },
501
+ * { vectorField: 'embedding', vector: queryVec },
502
+ * 5,
503
+ * );
504
+ */
505
+ hybridSearch(text: {
506
+ textField: keyof Omit<T, '_id'> & string;
507
+ text: string;
508
+ }, vector: {
509
+ vectorField: keyof Omit<T, '_id'> & string;
510
+ vector: number[];
511
+ }, topK: number, filter?: Filter<T>, options?: HybridSearchOptions): Promise<HybridSearchResult<T>[]>;
354
512
  /**
355
513
  * Return the indexes that currently exist on this collection.
356
514
  *
@@ -1321,4 +1479,4 @@ interface OpenDBOptions {
1321
1479
  */
1322
1480
  declare function openDB(dbName?: string, options?: OpenDBOptions): Promise<TalaDB>;
1323
1481
 
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 };
1482
+ 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 HybridSearchOptions, type HybridSearchResult, 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 TextSearchOptions, type TextSearchResult, type Update, type Value, type VectorIndexOptions, type VectorMetric, type VectorSearchResult, type WriteOrigin, applySchema, coverageKey, createRestSource, deriveDocId, isAuthoritative, openDB, progress, rowsApplied, runMigrations };