@dudousxd/nestjs-agent-rag 0.14.0 → 0.14.1

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.cts CHANGED
@@ -279,14 +279,15 @@ interface LexicalVectorStore extends VectorStore {
279
279
  searchText(query: string, options: VectorSearchOptions): Promise<Passage[]>;
280
280
  }
281
281
  /**
282
- * An **optional** capability: swap all of a document's chunks atomically, so no search or crash ever
283
- * sees it half-replaced. Optional because atomicity is exactly what not every backend can promise over
284
- * its client; {@link replaceDocumentChunks} falls back to `remove` plus `upsert` for the others.
282
+ * An **optional** capability: swap all of a document's chunks safely under concurrent calls, so two
283
+ * writers can't leave a mix of versions behind. Postgres also makes the swap atomic for searches and
284
+ * crashes; Redis can't, and only guarantees the end state. {@link replaceDocumentChunks} falls back to
285
+ * `remove` plus `upsert` for the others.
285
286
  */
286
287
  interface AtomicReplaceVectorStore extends VectorStore {
287
288
  replaceDocument(documentId: string, records: VectorRecord[]): Promise<void>;
288
289
  }
289
- /** Replaces a document's chunks, atomically where the store can. */
290
+ /** Replaces a document's chunks through the store's safe swap, where it has one. */
290
291
  declare function replaceDocumentChunks(store: VectorStore, documentId: string, records: VectorRecord[]): Promise<void>;
291
292
  /**
292
293
  * Does this store carry the optional {@link LexicalVectorStore} capability? Lets a consumer pick the
@@ -671,6 +672,10 @@ declare class PgVectorStore implements AtomicReplaceVectorStore {
671
672
  /**
672
673
  * Swaps a document's chunks in one transaction, so a crash can't lose it and a search sees the old
673
674
  * version or the new one. A client without `transaction` falls back to two statements.
675
+ *
676
+ * Concurrent swaps of one document take turns on an advisory lock. Without it the second `DELETE`
677
+ * can't see the rows the first just committed, so the first's tail survives next to the second's
678
+ * chunks, and overlapping writes can deadlock.
674
679
  */
675
680
  replaceDocument(documentId: string, records: VectorRecord[]): Promise<void>;
676
681
  private upsertWith;
@@ -770,12 +775,6 @@ declare class PgLexicalVectorStore extends PgVectorStore implements LexicalVecto
770
775
  private tsvector;
771
776
  }
772
777
 
773
- /**
774
- * The minimal Redis surface {@link RedisVectorStore} needs: send a raw command and get the reply.
775
- * Both `node-redis` (`client.sendCommand(args)`) and `ioredis` (wrap `redis.call`) satisfy it — so
776
- * this package pulls in NO Redis driver (bring your own, like the other adapters). Requires the
777
- * RediSearch module (Redis Stack / Redis 8+).
778
- */
779
778
  interface RedisSearchClient {
780
779
  sendCommand(args: (string | Buffer)[]): Promise<unknown>;
781
780
  }
@@ -837,7 +836,7 @@ interface RedisVectorStoreOptions {
837
836
  * {@link LexicalVectorStore}: {@link RedisVectorStore.searchText} is BM25 over that same index —
838
837
  * the lexical half of hybrid search with no second index to build, feed, or invalidate.
839
838
  */
840
- declare class RedisVectorStore implements LexicalVectorStore {
839
+ declare class RedisVectorStore implements LexicalVectorStore, AtomicReplaceVectorStore {
841
840
  private readonly client;
842
841
  private readonly index;
843
842
  private readonly prefix;
@@ -879,6 +878,18 @@ declare class RedisVectorStore implements LexicalVectorStore {
879
878
  private reconcileSchema;
880
879
  private createIndex;
881
880
  upsert(records: VectorRecord[]): Promise<void>;
881
+ /**
882
+ * Replaces a document's chunks, one writer at a time per document. Redis can't swap them in one step
883
+ * without scanning the keyspace inside a script, so a search may briefly see old and new chunks
884
+ * together, but concurrent calls can't leave them mixed. The new chunks go in before the old ones
885
+ * come out, so a crash leaves the document searchable and the next call repairs it.
886
+ */
887
+ replaceDocument(documentId: string, records: VectorRecord[]): Promise<void>;
888
+ /**
889
+ * A lease, so a crashed writer can't block the document forever. It is renewed while the work runs,
890
+ * which keeps a large document from outliving it.
891
+ */
892
+ private withDocumentLock;
882
893
  /**
883
894
  * Single-document deletion, by `SCAN … MATCH`.
884
895
  *
@@ -1700,8 +1711,9 @@ declare class RagIndex<Row, Scope extends RagScopeDefinition<Row>> {
1700
1711
  search(query: string, options: RagSearchOptions<Scope>): Promise<Passage[]>;
1701
1712
  /**
1702
1713
  * Replaces the row's chunks and returns how many were written. Embeddings come first: if the
1703
- * provider fails, the previous chunks are still there. The swap is one transaction on Postgres; on a
1704
- * store without `replaceDocument` a crash mid-swap loses the document until the next `index`.
1714
+ * provider fails, the previous chunks are still there. The swap is one transaction on Postgres. Redis
1715
+ * adds the new chunks before dropping the old, so a search can briefly see both. On a store without
1716
+ * `replaceDocument`, a crash mid-swap loses the document until the next `index`.
1705
1717
  */
1706
1718
  index(row: Row): Promise<number>;
1707
1719
  /** Takes the id itself once the row is gone. */
package/dist/index.d.ts CHANGED
@@ -279,14 +279,15 @@ interface LexicalVectorStore extends VectorStore {
279
279
  searchText(query: string, options: VectorSearchOptions): Promise<Passage[]>;
280
280
  }
281
281
  /**
282
- * An **optional** capability: swap all of a document's chunks atomically, so no search or crash ever
283
- * sees it half-replaced. Optional because atomicity is exactly what not every backend can promise over
284
- * its client; {@link replaceDocumentChunks} falls back to `remove` plus `upsert` for the others.
282
+ * An **optional** capability: swap all of a document's chunks safely under concurrent calls, so two
283
+ * writers can't leave a mix of versions behind. Postgres also makes the swap atomic for searches and
284
+ * crashes; Redis can't, and only guarantees the end state. {@link replaceDocumentChunks} falls back to
285
+ * `remove` plus `upsert` for the others.
285
286
  */
286
287
  interface AtomicReplaceVectorStore extends VectorStore {
287
288
  replaceDocument(documentId: string, records: VectorRecord[]): Promise<void>;
288
289
  }
289
- /** Replaces a document's chunks, atomically where the store can. */
290
+ /** Replaces a document's chunks through the store's safe swap, where it has one. */
290
291
  declare function replaceDocumentChunks(store: VectorStore, documentId: string, records: VectorRecord[]): Promise<void>;
291
292
  /**
292
293
  * Does this store carry the optional {@link LexicalVectorStore} capability? Lets a consumer pick the
@@ -671,6 +672,10 @@ declare class PgVectorStore implements AtomicReplaceVectorStore {
671
672
  /**
672
673
  * Swaps a document's chunks in one transaction, so a crash can't lose it and a search sees the old
673
674
  * version or the new one. A client without `transaction` falls back to two statements.
675
+ *
676
+ * Concurrent swaps of one document take turns on an advisory lock. Without it the second `DELETE`
677
+ * can't see the rows the first just committed, so the first's tail survives next to the second's
678
+ * chunks, and overlapping writes can deadlock.
674
679
  */
675
680
  replaceDocument(documentId: string, records: VectorRecord[]): Promise<void>;
676
681
  private upsertWith;
@@ -770,12 +775,6 @@ declare class PgLexicalVectorStore extends PgVectorStore implements LexicalVecto
770
775
  private tsvector;
771
776
  }
772
777
 
773
- /**
774
- * The minimal Redis surface {@link RedisVectorStore} needs: send a raw command and get the reply.
775
- * Both `node-redis` (`client.sendCommand(args)`) and `ioredis` (wrap `redis.call`) satisfy it — so
776
- * this package pulls in NO Redis driver (bring your own, like the other adapters). Requires the
777
- * RediSearch module (Redis Stack / Redis 8+).
778
- */
779
778
  interface RedisSearchClient {
780
779
  sendCommand(args: (string | Buffer)[]): Promise<unknown>;
781
780
  }
@@ -837,7 +836,7 @@ interface RedisVectorStoreOptions {
837
836
  * {@link LexicalVectorStore}: {@link RedisVectorStore.searchText} is BM25 over that same index —
838
837
  * the lexical half of hybrid search with no second index to build, feed, or invalidate.
839
838
  */
840
- declare class RedisVectorStore implements LexicalVectorStore {
839
+ declare class RedisVectorStore implements LexicalVectorStore, AtomicReplaceVectorStore {
841
840
  private readonly client;
842
841
  private readonly index;
843
842
  private readonly prefix;
@@ -879,6 +878,18 @@ declare class RedisVectorStore implements LexicalVectorStore {
879
878
  private reconcileSchema;
880
879
  private createIndex;
881
880
  upsert(records: VectorRecord[]): Promise<void>;
881
+ /**
882
+ * Replaces a document's chunks, one writer at a time per document. Redis can't swap them in one step
883
+ * without scanning the keyspace inside a script, so a search may briefly see old and new chunks
884
+ * together, but concurrent calls can't leave them mixed. The new chunks go in before the old ones
885
+ * come out, so a crash leaves the document searchable and the next call repairs it.
886
+ */
887
+ replaceDocument(documentId: string, records: VectorRecord[]): Promise<void>;
888
+ /**
889
+ * A lease, so a crashed writer can't block the document forever. It is renewed while the work runs,
890
+ * which keeps a large document from outliving it.
891
+ */
892
+ private withDocumentLock;
882
893
  /**
883
894
  * Single-document deletion, by `SCAN … MATCH`.
884
895
  *
@@ -1700,8 +1711,9 @@ declare class RagIndex<Row, Scope extends RagScopeDefinition<Row>> {
1700
1711
  search(query: string, options: RagSearchOptions<Scope>): Promise<Passage[]>;
1701
1712
  /**
1702
1713
  * Replaces the row's chunks and returns how many were written. Embeddings come first: if the
1703
- * provider fails, the previous chunks are still there. The swap is one transaction on Postgres; on a
1704
- * store without `replaceDocument` a crash mid-swap loses the document until the next `index`.
1714
+ * provider fails, the previous chunks are still there. The swap is one transaction on Postgres. Redis
1715
+ * adds the new chunks before dropping the old, so a search can briefly see both. On a store without
1716
+ * `replaceDocument`, a crash mid-swap loses the document until the next `index`.
1705
1717
  */
1706
1718
  index(row: Row): Promise<number>;
1707
1719
  /** Takes the id itself once the row is gone. */
package/dist/index.js CHANGED
@@ -679,6 +679,10 @@ var PgVectorStore = class {
679
679
  /**
680
680
  * Swaps a document's chunks in one transaction, so a crash can't lose it and a search sees the old
681
681
  * version or the new one. A client without `transaction` falls back to two statements.
682
+ *
683
+ * Concurrent swaps of one document take turns on an advisory lock. Without it the second `DELETE`
684
+ * can't see the rows the first just committed, so the first's tail survives next to the second's
685
+ * chunks, and overlapping writes can deadlock.
682
686
  */
683
687
  async replaceDocument(documentId, records) {
684
688
  const swap = /* @__PURE__ */ __name(async (client) => {
@@ -688,7 +692,16 @@ var PgVectorStore = class {
688
692
  await this.upsertWith(client, records);
689
693
  }, "swap");
690
694
  const transaction = this.client.transaction?.bind(this.client);
691
- await (transaction === void 0 ? swap(this.client) : transaction(swap));
695
+ if (transaction === void 0) {
696
+ await swap(this.client);
697
+ return;
698
+ }
699
+ await transaction(async (client) => {
700
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
701
+ `${this.table}:${stripNulBytes(documentId)}`
702
+ ]);
703
+ await swap(client);
704
+ });
692
705
  }
693
706
  async upsertWith(client, records) {
694
707
  const byId = /* @__PURE__ */ new Map();
@@ -1103,6 +1116,11 @@ function toVectorLiteral(embedding) {
1103
1116
  __name(toVectorLiteral, "toVectorLiteral");
1104
1117
 
1105
1118
  // src/redis-vector-store.ts
1119
+ import { randomUUID } from "crypto";
1120
+ var REPLACE_LOCK_TTL_MS = 3e4;
1121
+ var REPLACE_LOCK_WAIT_MS = 12e4;
1122
+ var RENEW_LOCK_SCRIPT = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) end return 0";
1123
+ var RELEASE_LOCK_SCRIPT = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0";
1106
1124
  var RedisVectorSchemaMismatchError = class extends Error {
1107
1125
  static {
1108
1126
  __name(this, "RedisVectorSchemaMismatchError");
@@ -1264,6 +1282,92 @@ var RedisVectorStore = class {
1264
1282
  }
1265
1283
  }
1266
1284
  /**
1285
+ * Replaces a document's chunks, one writer at a time per document. Redis can't swap them in one step
1286
+ * without scanning the keyspace inside a script, so a search may briefly see old and new chunks
1287
+ * together, but concurrent calls can't leave them mixed. The new chunks go in before the old ones
1288
+ * come out, so a crash leaves the document searchable and the next call repairs it.
1289
+ */
1290
+ async replaceDocument(documentId, records) {
1291
+ await this.withDocumentLock(documentId, async (assertHeld) => {
1292
+ await this.upsert(records);
1293
+ assertHeld();
1294
+ const current = new Set(records.map((record) => `${this.prefix}${record.id}`));
1295
+ const stale = (await this.chunkKeys(documentId)).filter((key) => !current.has(key));
1296
+ assertHeld();
1297
+ if (stale.length > 0) {
1298
+ await this.client.sendCommand([
1299
+ "DEL",
1300
+ ...stale
1301
+ ]);
1302
+ }
1303
+ });
1304
+ }
1305
+ /**
1306
+ * A lease, so a crashed writer can't block the document forever. It is renewed while the work runs,
1307
+ * which keeps a large document from outliving it.
1308
+ */
1309
+ async withDocumentLock(documentId, work) {
1310
+ const key = `${this.index}:replace-lock:${documentId}`;
1311
+ const token = randomUUID();
1312
+ const deadline = Date.now() + REPLACE_LOCK_WAIT_MS;
1313
+ let delay = 10;
1314
+ while (await this.client.sendCommand([
1315
+ "SET",
1316
+ key,
1317
+ token,
1318
+ "NX",
1319
+ "PX",
1320
+ String(REPLACE_LOCK_TTL_MS)
1321
+ ]) === null) {
1322
+ if (Date.now() >= deadline) {
1323
+ throw new Error(`RedisVectorStore: timed out after ${REPLACE_LOCK_WAIT_MS}ms waiting to replace document "${documentId}"`);
1324
+ }
1325
+ await new Promise((resolve) => setTimeout(resolve, delay));
1326
+ delay = Math.min(delay * 2, 250);
1327
+ }
1328
+ let lost = false;
1329
+ const assertHeld = /* @__PURE__ */ __name(() => {
1330
+ if (lost) {
1331
+ throw new Error(`RedisVectorStore: lost the lock while replacing document "${documentId}"; call again to repair it`);
1332
+ }
1333
+ }, "assertHeld");
1334
+ const renewal = setInterval(() => {
1335
+ void Promise.resolve(this.client.sendCommand([
1336
+ "EVAL",
1337
+ RENEW_LOCK_SCRIPT,
1338
+ "1",
1339
+ key,
1340
+ token,
1341
+ String(REPLACE_LOCK_TTL_MS)
1342
+ ])).then(
1343
+ (renewed) => {
1344
+ if (Number(renewed) === 0) {
1345
+ lost = true;
1346
+ }
1347
+ },
1348
+ // A failed renewal isn't a lost lock yet: the lease may still have time left.
1349
+ () => {
1350
+ }
1351
+ );
1352
+ }, REPLACE_LOCK_TTL_MS / 3);
1353
+ renewal.unref?.();
1354
+ try {
1355
+ const result = await work(assertHeld);
1356
+ assertHeld();
1357
+ return result;
1358
+ } finally {
1359
+ clearInterval(renewal);
1360
+ await Promise.resolve(this.client.sendCommand([
1361
+ "EVAL",
1362
+ RELEASE_LOCK_SCRIPT,
1363
+ "1",
1364
+ key,
1365
+ token
1366
+ ])).catch(() => {
1367
+ });
1368
+ }
1369
+ }
1370
+ /**
1267
1371
  * Single-document deletion, by `SCAN … MATCH`.
1268
1372
  *
1269
1373
  * This deliberately stays a keyspace scan even though the rest of this class no longer needs one.
@@ -2817,8 +2921,9 @@ var RagIndex = class {
2817
2921
  }
2818
2922
  /**
2819
2923
  * Replaces the row's chunks and returns how many were written. Embeddings come first: if the
2820
- * provider fails, the previous chunks are still there. The swap is one transaction on Postgres; on a
2821
- * store without `replaceDocument` a crash mid-swap loses the document until the next `index`.
2924
+ * provider fails, the previous chunks are still there. The swap is one transaction on Postgres. Redis
2925
+ * adds the new chunks before dropping the old, so a search can briefly see both. On a store without
2926
+ * `replaceDocument`, a crash mid-swap loses the document until the next `index`.
2822
2927
  */
2823
2928
  async index(row) {
2824
2929
  const { definition, deps } = this;