libsql-search 0.7.0 → 0.8.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
@@ -5,27 +5,13 @@
5
5
  [![CI](https://github.com/llbbl/libsql-search/actions/workflows/ci.yml/badge.svg)](https://github.com/llbbl/libsql-search/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
- `libsql-search` adds semantic search to Markdown-backed sites using libSQL/Turso.
9
- It indexes frontmatter and content from files on disk, stores vectors in your
10
- database, and lets you query by meaning instead of exact keywords.
8
+ `libsql-search` adds semantic search to Markdown-backed sites with a small TypeScript API. It indexes frontmatter and content from files on disk, stores vectors in libSQL/Turso, and lets you query by meaning instead of exact keywords.
11
9
 
12
10
  Use it when you want:
13
11
 
14
- - a small TypeScript library instead of a hosted search product
15
- - one search index shared across static-site builds and app routes
16
- - local or hosted embeddings behind the same indexing/search API
17
- - direct control over table names, dimensions, content shape, and deployment
18
-
19
- ## What It Supports
20
-
21
- - Markdown indexing from local directories with frontmatter via `gray-matter`
22
- - libSQL/Turso storage and vector search
23
- - Embedding providers: local Hugging Face `Xenova/all-MiniLM-L6-v2`,
24
- Cloudflare Workers AI `@cf/baai/bge-m3`, Mistral `mistral-embed`, Google Gemini
25
- `gemini-embedding-2`, and OpenAI `text-embedding-3-small` /
26
- `text-embedding-3-large`; self-hosted OpenAI-compatible endpoints are also
27
- available for TEI and similar trusted deployments
28
- - npm distribution plus JSR publishing
12
+ - one indexing/search API across local and hosted embedding providers
13
+ - direct control over vector dimensions, table names, and deployment shape
14
+ - a lightweight library instead of a hosted search product
29
15
 
30
16
  ## Install
31
17
 
@@ -44,18 +30,10 @@ deno add jsr:@logan/libsql-search npm:@libsql/client
44
30
  ```
45
31
 
46
32
  For npm usage, the package requires Node `>=22.12.0`.
47
- Node examples in this README import from `libsql-search` and `@libsql/client`.
48
- In Deno, after `deno add`, import from `@logan/libsql-search` and
49
- `@libsql/client`.
50
33
 
51
34
  ## Quick Start
52
35
 
53
- The shortest working flow is:
54
-
55
- 1. create a libSQL client
56
- 2. create the search table
57
- 3. index a Markdown directory
58
- 4. query it with the same embedding provider and dimensions
36
+ This example uses the default local provider. Local embeddings run in-process after the initial model download and cache warmup; they are not automatically air-gapped.
59
37
 
60
38
  ```ts
61
39
  import { createClient } from "@libsql/client";
@@ -66,11 +44,12 @@ const client = createClient({
66
44
  authToken: "your-auth-token",
67
45
  });
68
46
 
69
- await createTable(client);
47
+ await createTable(client, "articles_local_384", 384);
70
48
 
71
49
  await indexContent({
72
50
  client,
73
51
  contentPath: "./content",
52
+ tableName: "articles_local_384",
74
53
  embeddingOptions: {
75
54
  provider: "local",
76
55
  },
@@ -79,6 +58,7 @@ await indexContent({
79
58
  const results = await search({
80
59
  client,
81
60
  query: "how do I deploy my docs site",
61
+ tableName: "articles_local_384",
82
62
  limit: 5,
83
63
  embeddingOptions: {
84
64
  provider: "local",
@@ -95,33 +75,33 @@ console.log(results.map((result) => ({
95
75
  Important behavior:
96
76
 
97
77
  - Call `createTable()` before indexing or searching.
98
- - Keep dimensions aligned across table creation, indexing, and search queries.
99
- - `indexContent()` clears existing rows before rebuilding the index.
100
- - `local` is the default offline provider and uses 384 dimensions. Cloudflare is
101
- the recommended hosted option. Cloudflare and Mistral use 1024 dimensions.
102
- Gemini defaults to 3072 dimensions and supports 128-3072.
103
-
104
- ## Core API
105
-
106
- - `createTable(client, tableName?, dimensions?)`
107
- - `indexContent(options)`
108
- - `search(options)`
109
- - `getAllArticles(client, tableName?)`
110
- - `getArticleBySlug(client, slug, tableName?)`
111
- - `getArticlesByFolder(client, folder, tableName?)`
112
- - `getFolders(client, tableName?)`
113
- - `generateEmbedding(text, options?)`
114
- - `prepareTextForEmbedding(fields)`
78
+ - Keep table width, provider, and dimensions aligned across create/index/query.
79
+ - `indexContent()` embeds every document before it touches the database, then replaces the table in one transaction, so a failed rebuild leaves the previous index intact.
80
+ - `indexContent()` throws `IndexingError` when a file fails; pass `failurePolicy: "skip"` to rebuild from the remaining files.
81
+ - `indexContent()` throws `IndexingError` when no source files are found; pass `allowEmptyIndex: true` to intentionally empty the index.
82
+ - Hosted providers send indexed and queried text to external services and may incur provider charges.
83
+
84
+ ## Providers
85
+
86
+ Built-in providers:
87
+
88
+ - `local` with `Xenova/all-MiniLM-L6-v2` at 384 dimensions
89
+ - `cloudflare` with `@cf/baai/bge-m3` at 1024 dimensions
90
+ - `mistral` with `mistral-embed` at 1024 dimensions
91
+ - `gemini` with `gemini-embedding-2` at 128-3072 dimensions, default 3072
92
+ - `openai` with `text-embedding-3-small` or `text-embedding-3-large`, default 768
93
+ - `openai-compatible` for trusted OpenAI-compatible endpoints such as TEI
115
94
 
116
95
  ## Docs
117
96
 
118
- - [Docs index](./docs/README.md)
119
- - [Provider guide](./docs/PROVIDERS.md)
97
+ - [Documentation index](./docs/README.md)
98
+ - [Provider selection and configuration](./docs/PROVIDERS.md)
120
99
  - [API reference](./docs/API.md)
121
100
  - [Integration examples](./docs/INTEGRATIONS.md)
101
+ - [Migration and reindexing guide](./docs/MIGRATIONS.md)
102
+ - [Testing guidance](./docs/TESTING.md)
122
103
  - [Indexing and operations](./docs/INDEXING.md)
123
104
  - [Troubleshooting](./docs/TROUBLESHOOTING.md)
124
- - [Release workflow](./docs/RELEASING.md)
125
105
 
126
106
  ## License
127
107
 
package/dist/index.cjs CHANGED
@@ -813,6 +813,16 @@ function normalizeVectorDimensions(dimensions) {
813
813
  return dimensions;
814
814
  }
815
815
 
816
+ class IndexingError extends Error {
817
+ phase;
818
+ failures;
819
+ constructor(message, phase, failures = [], options) {
820
+ super(message, options);
821
+ this.name = "IndexingError";
822
+ this.phase = phase;
823
+ this.failures = failures;
824
+ }
825
+ }
816
826
  async function indexContent(options) {
817
827
  const {
818
828
  client,
@@ -821,32 +831,92 @@ async function indexContent(options) {
821
831
  fileExtensions = [".md", ".markdown"],
822
832
  exclude = ["node_modules", ".git", "dist", "build"],
823
833
  tableName = "articles",
824
- onProgress
834
+ onProgress,
835
+ failurePolicy = "abort",
836
+ allowEmptyIndex = false
825
837
  } = options;
826
838
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
827
- const files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
828
- if (files.length === 0) {
829
- console.warn(`No files found in ${contentPath}`);
830
- return { success: 0, failed: 0, total: 0 };
831
- }
832
- await client.execute(`DELETE FROM ${quotedTableName}`);
833
- let success = 0;
834
- let failed = 0;
839
+ let files;
840
+ try {
841
+ files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
842
+ } catch (error) {
843
+ throw new IndexingError(
844
+ `Failed to scan ${contentPath} for source files. The existing index was left unchanged.`,
845
+ "build",
846
+ [],
847
+ { cause: toError(error) }
848
+ );
849
+ }
850
+ files.sort((a, b) => a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0);
851
+ if (files.length === 0 && !allowEmptyIndex) {
852
+ throw new IndexingError(
853
+ `No source files found in ${contentPath}. The existing index was left unchanged. Pass allowEmptyIndex: true to intentionally empty the index.`,
854
+ "build"
855
+ );
856
+ }
857
+ const documents = [];
858
+ const failures = [];
859
+ const slugOwners = /* @__PURE__ */ new Map();
835
860
  for (let i = 0; i < files.length; i++) {
836
861
  const file = files[i];
837
862
  if (onProgress) {
838
863
  onProgress(i + 1, files.length, file.relativePath);
839
864
  }
840
- try {
841
- const document = await processFile(file, embeddingOptions);
842
- await insertDocument(client, document, quotedTableName);
843
- success++;
844
- } catch (error) {
845
- console.error(`Failed to index ${file.relativePath}:`, error);
846
- failed++;
865
+ const outcome = await buildDocument(file, embeddingOptions);
866
+ let failure;
867
+ if (outcome.ok) {
868
+ const owner = slugOwners.get(outcome.document.slug);
869
+ if (owner === void 0) {
870
+ slugOwners.set(outcome.document.slug, file.relativePath);
871
+ documents.push(outcome.document);
872
+ } else {
873
+ failure = {
874
+ file: file.relativePath,
875
+ stage: "parse",
876
+ error: new Error(
877
+ `Duplicate slug "${outcome.document.slug}": ${file.relativePath} collides with ${owner}`
878
+ )
879
+ };
880
+ }
881
+ } else {
882
+ failure = {
883
+ file: file.relativePath,
884
+ stage: outcome.stage,
885
+ error: outcome.error
886
+ };
887
+ }
888
+ if (failure === void 0) {
889
+ continue;
847
890
  }
891
+ if (failurePolicy === "abort") {
892
+ throw new IndexingError(
893
+ `Failed to ${failure.stage} ${file.relativePath}: ${failure.error.message}. The existing index was left unchanged. Pass failurePolicy: 'skip' to rebuild from the remaining files.`,
894
+ "build",
895
+ [failure],
896
+ { cause: failure.error }
897
+ );
898
+ }
899
+ console.error(`Skipping ${file.relativePath} (${failure.stage} failed):`, failure.error);
900
+ failures.push(failure);
848
901
  }
849
- return { success, failed, total: files.length };
902
+ if (files.length > 0 && documents.length === 0) {
903
+ throw new IndexingError(
904
+ `All ${files.length} source file(s) in ${contentPath} failed to index. The existing index was left unchanged.`,
905
+ "build",
906
+ failures,
907
+ { cause: failures[0].error }
908
+ );
909
+ }
910
+ const success = documents.length;
911
+ await replaceIndex(client, quotedTableName, documents, failures);
912
+ return {
913
+ success,
914
+ failed: failures.length,
915
+ total: files.length,
916
+ replaced: true,
917
+ partial: failures.length > 0,
918
+ failures
919
+ };
850
920
  }
851
921
  async function findFiles(dir, baseDir, extensions, exclude) {
852
922
  const files = [];
@@ -870,34 +940,113 @@ async function findFiles(dir, baseDir, extensions, exclude) {
870
940
  }
871
941
  return files;
872
942
  }
873
- async function processFile(file, embeddingOptions) {
874
- const content = await promises.readFile(file.fullPath, "utf-8");
875
- const { data: frontMatter, content: markdown } = matter(content);
943
+ async function buildDocument(file, embeddingOptions) {
944
+ let raw;
945
+ try {
946
+ raw = await promises.readFile(file.fullPath, "utf-8");
947
+ } catch (error) {
948
+ return { ok: false, stage: "read", error: toError(error) };
949
+ }
950
+ let parsed;
951
+ try {
952
+ parsed = parseFile(file, raw);
953
+ } catch (error) {
954
+ return { ok: false, stage: "parse", error: toError(error) };
955
+ }
956
+ let embedding;
957
+ try {
958
+ embedding = await generateEmbedding(parsed.embeddingText, {
959
+ ...embeddingOptions,
960
+ intent: embeddingOptions.intent ?? "document"
961
+ });
962
+ } catch (error) {
963
+ return { ok: false, stage: "embed", error: toError(error) };
964
+ }
965
+ return {
966
+ ok: true,
967
+ document: {
968
+ slug: parsed.slug,
969
+ title: parsed.title,
970
+ content: parsed.content,
971
+ folder: file.folder,
972
+ tags: parsed.tags,
973
+ serializedTags: parsed.serializedTags,
974
+ embedding,
975
+ metadata: parsed.metadata
976
+ }
977
+ };
978
+ }
979
+ function parseFile(file, raw) {
980
+ const { data: frontMatter, content: markdown } = matter(raw);
876
981
  const slug = file.relativePath.replace(/\.(md|markdown)$/, "").replace(/\\/g, "/");
877
- const title = frontMatter.title || file.relativePath.split("/").pop()?.replace(/\.(md|markdown)$/, "").replace(/-/g, " ") || "Untitled";
982
+ const title = resolveTitle(file, frontMatter.title);
878
983
  const tags = Array.isArray(frontMatter.tags) ? frontMatter.tags : [];
984
+ const serializedTags = JSON.stringify(tags);
879
985
  const embeddingText = prepareTextForEmbedding({
880
986
  title,
881
987
  description: frontMatter.description,
882
988
  content: markdown,
883
989
  tags
884
990
  });
885
- const embedding = await generateEmbedding(embeddingText, {
886
- ...embeddingOptions,
887
- intent: embeddingOptions.intent ?? "document"
888
- });
889
991
  return {
890
992
  slug,
891
993
  title,
892
994
  content: markdown,
893
- folder: file.folder,
894
995
  tags,
895
- embedding,
996
+ serializedTags,
997
+ embeddingText,
896
998
  metadata: frontMatter
897
999
  };
898
1000
  }
899
- async function insertDocument(client, document, quotedTableName) {
900
- await client.execute({
1001
+ function resolveTitle(file, rawTitle) {
1002
+ if (!rawTitle) {
1003
+ return fallbackTitle(file);
1004
+ }
1005
+ if (typeof rawTitle === "string") {
1006
+ return rawTitle;
1007
+ }
1008
+ if (typeof rawTitle === "number" || typeof rawTitle === "bigint" || typeof rawTitle === "boolean") {
1009
+ return String(rawTitle);
1010
+ }
1011
+ if (rawTitle instanceof Date) {
1012
+ return rawTitle.toISOString();
1013
+ }
1014
+ throw new Error(
1015
+ `Unsupported frontmatter title of type ${describeType(rawTitle)}: expected a string`
1016
+ );
1017
+ }
1018
+ function fallbackTitle(file) {
1019
+ return file.relativePath.split("/").pop()?.replace(/\.(md|markdown)$/, "").replace(/-/g, " ") || "Untitled";
1020
+ }
1021
+ function describeType(value) {
1022
+ return Array.isArray(value) ? "array" : typeof value;
1023
+ }
1024
+ async function replaceIndex(client, quotedTableName, documents, failures) {
1025
+ const statements = [`DELETE FROM ${quotedTableName}`];
1026
+ try {
1027
+ for (let i = 0; i < documents.length; i++) {
1028
+ const document = documents[i];
1029
+ if (document !== void 0) {
1030
+ statements.push(createInsertStatement(document, quotedTableName));
1031
+ documents[i] = void 0;
1032
+ }
1033
+ }
1034
+ } finally {
1035
+ documents.length = 0;
1036
+ }
1037
+ try {
1038
+ await client.batch(statements, "write");
1039
+ } catch (error) {
1040
+ throw new IndexingError(
1041
+ `Failed to replace the contents of ${quotedTableName}. The transaction was rolled back and the existing index was left unchanged.`,
1042
+ "replace",
1043
+ failures,
1044
+ { cause: toError(error) }
1045
+ );
1046
+ }
1047
+ }
1048
+ function createInsertStatement(document, quotedTableName) {
1049
+ return {
901
1050
  sql: `INSERT INTO ${quotedTableName}
902
1051
  (slug, title, content, folder, tags, embedding, created_at, updated_at)
903
1052
  VALUES (?, ?, ?, ?, ?, vector(?), datetime('now'), datetime('now'))`,
@@ -906,10 +1055,13 @@ async function insertDocument(client, document, quotedTableName) {
906
1055
  document.title,
907
1056
  document.content,
908
1057
  document.folder,
909
- JSON.stringify(document.tags),
1058
+ document.serializedTags,
910
1059
  JSON.stringify(document.embedding)
911
1060
  ]
912
- });
1061
+ };
1062
+ }
1063
+ function toError(error) {
1064
+ return error instanceof Error ? error : new Error(String(error));
913
1065
  }
914
1066
  async function createTable(client, tableName = "articles", dimensions = 384) {
915
1067
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
@@ -1059,6 +1211,7 @@ async function getFolders(client, tableName = "articles") {
1059
1211
  return results.rows.map((row) => row.folder);
1060
1212
  }
1061
1213
 
1214
+ exports.IndexingError = IndexingError;
1062
1215
  exports.createEmbeddingProvider = createEmbeddingProvider;
1063
1216
  exports.createTable = createTable;
1064
1217
  exports.generateEmbedding = generateEmbedding;
package/dist/index.d.ts CHANGED
@@ -82,6 +82,28 @@ declare function prepareTextForEmbedding(fields: {
82
82
  * Content indexer for markdown and other formats
83
83
  */
84
84
 
85
+ /**
86
+ * How build-phase failures are handled.
87
+ *
88
+ * - `abort` (default) rejects the whole rebuild on the first failure
89
+ * - `skip` drops the failing file and rebuilds from the survivors
90
+ */
91
+ type IndexFailurePolicy = 'abort' | 'skip';
92
+ /** The step that failed while turning a file into an indexable document. */
93
+ type IndexFailureStage = 'read' | 'parse' | 'embed';
94
+ /**
95
+ * The phase an {@link IndexingError} was raised in.
96
+ *
97
+ * - `build` means no database state was touched
98
+ * - `replace` means the replacement transaction failed and was rolled back
99
+ */
100
+ type IndexingErrorPhase = 'build' | 'replace';
101
+ interface IndexFailure {
102
+ /** Path of the file relative to `contentPath`. */
103
+ file: string;
104
+ stage: IndexFailureStage;
105
+ error: Error;
106
+ }
85
107
  interface IndexerOptions {
86
108
  client: Client;
87
109
  contentPath: string;
@@ -90,6 +112,23 @@ interface IndexerOptions {
90
112
  exclude?: string[];
91
113
  tableName?: string;
92
114
  onProgress?: (current: number, total: number, file: string) => void;
115
+ /** Defaults to `abort`. */
116
+ failurePolicy?: IndexFailurePolicy;
117
+ /** Allow an empty source directory to empty the index. Defaults to `false`. */
118
+ allowEmptyIndex?: boolean;
119
+ }
120
+ interface IndexResult {
121
+ /** Documents written to the table. */
122
+ success: number;
123
+ /** Files that could not be indexed. */
124
+ failed: number;
125
+ /** Files discovered on disk. */
126
+ total: number;
127
+ /** Whether table contents were replaced by this call. */
128
+ replaced: boolean;
129
+ /** Replaced, but some files were skipped. */
130
+ partial: boolean;
131
+ failures: IndexFailure[];
93
132
  }
94
133
  interface IndexedDocument {
95
134
  slug: string;
@@ -101,13 +140,22 @@ interface IndexedDocument {
101
140
  metadata?: Record<string, any>;
102
141
  }
103
142
  /**
104
- * Index markdown content from a directory
143
+ * Raised when a rebuild cannot complete. The previously indexed rows are always
144
+ * left exactly as they were.
105
145
  */
106
- declare function indexContent(options: IndexerOptions): Promise<{
107
- success: number;
108
- failed: number;
109
- total: number;
110
- }>;
146
+ declare class IndexingError extends Error {
147
+ readonly phase: IndexingErrorPhase;
148
+ readonly failures: IndexFailure[];
149
+ constructor(message: string, phase: IndexingErrorPhase, failures?: IndexFailure[], options?: ErrorOptions);
150
+ }
151
+ /**
152
+ * Index markdown content from a directory.
153
+ *
154
+ * Every document is read, parsed, and embedded in memory before any database
155
+ * state changes. The table is then replaced inside a single write transaction,
156
+ * so a failure at any point leaves the previous index intact.
157
+ */
158
+ declare function indexContent(options: IndexerOptions): Promise<IndexResult>;
111
159
  /**
112
160
  * Create the articles table if it doesn't exist
113
161
  */
@@ -178,5 +226,5 @@ declare function getArticlesByFolder(client: Client, folder: string, tableName?:
178
226
  */
179
227
  declare function getFolders(client: Client, tableName?: string): Promise<string[]>;
180
228
 
181
- export { createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
182
- export type { EmbeddingBatchBehavior, EmbeddingBatchItem, EmbeddingBatchItemResult, EmbeddingBatchMode, EmbeddingBatchResult, EmbeddingIntent, EmbeddingOptions, EmbeddingProvider, EmbeddingProviderClient, EmbeddingProviderMetadata, EmbeddingRequestOptions, IndexedDocument, IndexerOptions, SearchOptions, SearchResult };
229
+ export { IndexingError, createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
230
+ export type { EmbeddingBatchBehavior, EmbeddingBatchItem, EmbeddingBatchItemResult, EmbeddingBatchMode, EmbeddingBatchResult, EmbeddingIntent, EmbeddingOptions, EmbeddingProvider, EmbeddingProviderClient, EmbeddingProviderMetadata, EmbeddingRequestOptions, IndexFailure, IndexFailurePolicy, IndexFailureStage, IndexResult, IndexedDocument, IndexerOptions, IndexingErrorPhase, SearchOptions, SearchResult };