libsql-search 0.7.1 → 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
@@ -76,7 +76,9 @@ Important behavior:
76
76
 
77
77
  - Call `createTable()` before indexing or searching.
78
78
  - Keep table width, provider, and dimensions aligned across create/index/query.
79
- - `indexContent()` clears existing rows before rebuilding and is not transactional.
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.
80
82
  - Hosted providers send indexed and queried text to external services and may incur provider charges.
81
83
 
82
84
  ## Providers
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 };
package/dist/index.esm.js CHANGED
@@ -811,6 +811,16 @@ function normalizeVectorDimensions(dimensions) {
811
811
  return dimensions;
812
812
  }
813
813
 
814
+ class IndexingError extends Error {
815
+ phase;
816
+ failures;
817
+ constructor(message, phase, failures = [], options) {
818
+ super(message, options);
819
+ this.name = "IndexingError";
820
+ this.phase = phase;
821
+ this.failures = failures;
822
+ }
823
+ }
814
824
  async function indexContent(options) {
815
825
  const {
816
826
  client,
@@ -819,32 +829,92 @@ async function indexContent(options) {
819
829
  fileExtensions = [".md", ".markdown"],
820
830
  exclude = ["node_modules", ".git", "dist", "build"],
821
831
  tableName = "articles",
822
- onProgress
832
+ onProgress,
833
+ failurePolicy = "abort",
834
+ allowEmptyIndex = false
823
835
  } = options;
824
836
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
825
- const files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
826
- if (files.length === 0) {
827
- console.warn(`No files found in ${contentPath}`);
828
- return { success: 0, failed: 0, total: 0 };
829
- }
830
- await client.execute(`DELETE FROM ${quotedTableName}`);
831
- let success = 0;
832
- let failed = 0;
837
+ let files;
838
+ try {
839
+ files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
840
+ } catch (error) {
841
+ throw new IndexingError(
842
+ `Failed to scan ${contentPath} for source files. The existing index was left unchanged.`,
843
+ "build",
844
+ [],
845
+ { cause: toError(error) }
846
+ );
847
+ }
848
+ files.sort((a, b) => a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0);
849
+ if (files.length === 0 && !allowEmptyIndex) {
850
+ throw new IndexingError(
851
+ `No source files found in ${contentPath}. The existing index was left unchanged. Pass allowEmptyIndex: true to intentionally empty the index.`,
852
+ "build"
853
+ );
854
+ }
855
+ const documents = [];
856
+ const failures = [];
857
+ const slugOwners = /* @__PURE__ */ new Map();
833
858
  for (let i = 0; i < files.length; i++) {
834
859
  const file = files[i];
835
860
  if (onProgress) {
836
861
  onProgress(i + 1, files.length, file.relativePath);
837
862
  }
838
- try {
839
- const document = await processFile(file, embeddingOptions);
840
- await insertDocument(client, document, quotedTableName);
841
- success++;
842
- } catch (error) {
843
- console.error(`Failed to index ${file.relativePath}:`, error);
844
- failed++;
863
+ const outcome = await buildDocument(file, embeddingOptions);
864
+ let failure;
865
+ if (outcome.ok) {
866
+ const owner = slugOwners.get(outcome.document.slug);
867
+ if (owner === void 0) {
868
+ slugOwners.set(outcome.document.slug, file.relativePath);
869
+ documents.push(outcome.document);
870
+ } else {
871
+ failure = {
872
+ file: file.relativePath,
873
+ stage: "parse",
874
+ error: new Error(
875
+ `Duplicate slug "${outcome.document.slug}": ${file.relativePath} collides with ${owner}`
876
+ )
877
+ };
878
+ }
879
+ } else {
880
+ failure = {
881
+ file: file.relativePath,
882
+ stage: outcome.stage,
883
+ error: outcome.error
884
+ };
885
+ }
886
+ if (failure === void 0) {
887
+ continue;
845
888
  }
889
+ if (failurePolicy === "abort") {
890
+ throw new IndexingError(
891
+ `Failed to ${failure.stage} ${file.relativePath}: ${failure.error.message}. The existing index was left unchanged. Pass failurePolicy: 'skip' to rebuild from the remaining files.`,
892
+ "build",
893
+ [failure],
894
+ { cause: failure.error }
895
+ );
896
+ }
897
+ console.error(`Skipping ${file.relativePath} (${failure.stage} failed):`, failure.error);
898
+ failures.push(failure);
846
899
  }
847
- return { success, failed, total: files.length };
900
+ if (files.length > 0 && documents.length === 0) {
901
+ throw new IndexingError(
902
+ `All ${files.length} source file(s) in ${contentPath} failed to index. The existing index was left unchanged.`,
903
+ "build",
904
+ failures,
905
+ { cause: failures[0].error }
906
+ );
907
+ }
908
+ const success = documents.length;
909
+ await replaceIndex(client, quotedTableName, documents, failures);
910
+ return {
911
+ success,
912
+ failed: failures.length,
913
+ total: files.length,
914
+ replaced: true,
915
+ partial: failures.length > 0,
916
+ failures
917
+ };
848
918
  }
849
919
  async function findFiles(dir, baseDir, extensions, exclude) {
850
920
  const files = [];
@@ -868,34 +938,113 @@ async function findFiles(dir, baseDir, extensions, exclude) {
868
938
  }
869
939
  return files;
870
940
  }
871
- async function processFile(file, embeddingOptions) {
872
- const content = await readFile(file.fullPath, "utf-8");
873
- const { data: frontMatter, content: markdown } = matter(content);
941
+ async function buildDocument(file, embeddingOptions) {
942
+ let raw;
943
+ try {
944
+ raw = await readFile(file.fullPath, "utf-8");
945
+ } catch (error) {
946
+ return { ok: false, stage: "read", error: toError(error) };
947
+ }
948
+ let parsed;
949
+ try {
950
+ parsed = parseFile(file, raw);
951
+ } catch (error) {
952
+ return { ok: false, stage: "parse", error: toError(error) };
953
+ }
954
+ let embedding;
955
+ try {
956
+ embedding = await generateEmbedding(parsed.embeddingText, {
957
+ ...embeddingOptions,
958
+ intent: embeddingOptions.intent ?? "document"
959
+ });
960
+ } catch (error) {
961
+ return { ok: false, stage: "embed", error: toError(error) };
962
+ }
963
+ return {
964
+ ok: true,
965
+ document: {
966
+ slug: parsed.slug,
967
+ title: parsed.title,
968
+ content: parsed.content,
969
+ folder: file.folder,
970
+ tags: parsed.tags,
971
+ serializedTags: parsed.serializedTags,
972
+ embedding,
973
+ metadata: parsed.metadata
974
+ }
975
+ };
976
+ }
977
+ function parseFile(file, raw) {
978
+ const { data: frontMatter, content: markdown } = matter(raw);
874
979
  const slug = file.relativePath.replace(/\.(md|markdown)$/, "").replace(/\\/g, "/");
875
- const title = frontMatter.title || file.relativePath.split("/").pop()?.replace(/\.(md|markdown)$/, "").replace(/-/g, " ") || "Untitled";
980
+ const title = resolveTitle(file, frontMatter.title);
876
981
  const tags = Array.isArray(frontMatter.tags) ? frontMatter.tags : [];
982
+ const serializedTags = JSON.stringify(tags);
877
983
  const embeddingText = prepareTextForEmbedding({
878
984
  title,
879
985
  description: frontMatter.description,
880
986
  content: markdown,
881
987
  tags
882
988
  });
883
- const embedding = await generateEmbedding(embeddingText, {
884
- ...embeddingOptions,
885
- intent: embeddingOptions.intent ?? "document"
886
- });
887
989
  return {
888
990
  slug,
889
991
  title,
890
992
  content: markdown,
891
- folder: file.folder,
892
993
  tags,
893
- embedding,
994
+ serializedTags,
995
+ embeddingText,
894
996
  metadata: frontMatter
895
997
  };
896
998
  }
897
- async function insertDocument(client, document, quotedTableName) {
898
- await client.execute({
999
+ function resolveTitle(file, rawTitle) {
1000
+ if (!rawTitle) {
1001
+ return fallbackTitle(file);
1002
+ }
1003
+ if (typeof rawTitle === "string") {
1004
+ return rawTitle;
1005
+ }
1006
+ if (typeof rawTitle === "number" || typeof rawTitle === "bigint" || typeof rawTitle === "boolean") {
1007
+ return String(rawTitle);
1008
+ }
1009
+ if (rawTitle instanceof Date) {
1010
+ return rawTitle.toISOString();
1011
+ }
1012
+ throw new Error(
1013
+ `Unsupported frontmatter title of type ${describeType(rawTitle)}: expected a string`
1014
+ );
1015
+ }
1016
+ function fallbackTitle(file) {
1017
+ return file.relativePath.split("/").pop()?.replace(/\.(md|markdown)$/, "").replace(/-/g, " ") || "Untitled";
1018
+ }
1019
+ function describeType(value) {
1020
+ return Array.isArray(value) ? "array" : typeof value;
1021
+ }
1022
+ async function replaceIndex(client, quotedTableName, documents, failures) {
1023
+ const statements = [`DELETE FROM ${quotedTableName}`];
1024
+ try {
1025
+ for (let i = 0; i < documents.length; i++) {
1026
+ const document = documents[i];
1027
+ if (document !== void 0) {
1028
+ statements.push(createInsertStatement(document, quotedTableName));
1029
+ documents[i] = void 0;
1030
+ }
1031
+ }
1032
+ } finally {
1033
+ documents.length = 0;
1034
+ }
1035
+ try {
1036
+ await client.batch(statements, "write");
1037
+ } catch (error) {
1038
+ throw new IndexingError(
1039
+ `Failed to replace the contents of ${quotedTableName}. The transaction was rolled back and the existing index was left unchanged.`,
1040
+ "replace",
1041
+ failures,
1042
+ { cause: toError(error) }
1043
+ );
1044
+ }
1045
+ }
1046
+ function createInsertStatement(document, quotedTableName) {
1047
+ return {
899
1048
  sql: `INSERT INTO ${quotedTableName}
900
1049
  (slug, title, content, folder, tags, embedding, created_at, updated_at)
901
1050
  VALUES (?, ?, ?, ?, ?, vector(?), datetime('now'), datetime('now'))`,
@@ -904,10 +1053,13 @@ async function insertDocument(client, document, quotedTableName) {
904
1053
  document.title,
905
1054
  document.content,
906
1055
  document.folder,
907
- JSON.stringify(document.tags),
1056
+ document.serializedTags,
908
1057
  JSON.stringify(document.embedding)
909
1058
  ]
910
- });
1059
+ };
1060
+ }
1061
+ function toError(error) {
1062
+ return error instanceof Error ? error : new Error(String(error));
911
1063
  }
912
1064
  async function createTable(client, tableName = "articles", dimensions = 384) {
913
1065
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
@@ -1057,4 +1209,4 @@ async function getFolders(client, tableName = "articles") {
1057
1209
  return results.rows.map((row) => row.folder);
1058
1210
  }
1059
1211
 
1060
- export { createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
1212
+ export { IndexingError, createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
package/docs/API.md CHANGED
@@ -18,6 +18,7 @@
18
18
  - `validateEmbeddingBatch`
19
19
  - `padEmbedding`
20
20
  - `prepareTextForEmbedding`
21
+ - `IndexingError`
21
22
 
22
23
  It also exports these types:
23
24
 
@@ -34,6 +35,11 @@ It also exports these types:
34
35
  - `EmbeddingOptions`
35
36
  - `IndexerOptions`
36
37
  - `IndexedDocument`
38
+ - `IndexResult`
39
+ - `IndexFailure`
40
+ - `IndexFailurePolicy`
41
+ - `IndexFailureStage`
42
+ - `IndexingErrorPhase`
37
43
  - `SearchOptions`
38
44
  - `SearchResult`
39
45
 
@@ -86,6 +92,8 @@ interface IndexerOptions {
86
92
  exclude?: string[];
87
93
  tableName?: string;
88
94
  onProgress?: (current: number, total: number, file: string) => void;
95
+ failurePolicy?: "abort" | "skip";
96
+ allowEmptyIndex?: boolean;
89
97
  }
90
98
  ```
91
99
 
@@ -94,25 +102,80 @@ Defaults:
94
102
  - `fileExtensions`: [".md", ".markdown"]
95
103
  - `exclude`: ["node_modules", ".git", "dist", "build"]
96
104
  - `tableName`: `"articles"`
105
+ - `failurePolicy`: `"abort"`
106
+ - `allowEmptyIndex`: `false`
97
107
 
98
108
  Return shape:
99
109
 
100
110
  ```ts
101
- {
102
- success: number;
103
- failed: number;
104
- total: number;
111
+ interface IndexResult {
112
+ success: number; // documents written
113
+ failed: number; // files that could not be indexed
114
+ total: number; // files discovered on disk
115
+ replaced: boolean; // whether table contents were replaced by this call
116
+ partial: boolean; // replaced, but some files were skipped
117
+ failures: IndexFailure[];
118
+ }
119
+
120
+ interface IndexFailure {
121
+ file: string; // path relative to contentPath
122
+ stage: "read" | "parse" | "embed";
123
+ error: Error;
105
124
  }
106
125
  ```
107
126
 
108
127
  Behavior notes:
109
128
 
110
- - `indexContent()` deletes existing rows in the target table before rebuilding
111
- - rebuilds are not transactional
129
+ - every file is read, parsed, and embedded in memory before any database state changes
130
+ - the target table is then replaced in a single write transaction, so a failed rebuild leaves the previous index exactly as it was
131
+ - that costs peak memory proportional to the whole corpus, and against remote clients the replacement travels as a single un-chunked batch request; see [Costs of the two-phase rebuild](./INDEXING.md#costs-of-the-two-phase-rebuild) before rebuilding a very large corpus in place
132
+ - files are discovered and indexed in sorted path order
133
+ - frontmatter `title` must be a scalar; a structured title such as a YAML list fails the file at the `parse` stage
134
+ - two files that reduce to the same slug (`foo.md` and `foo.markdown`) collide: the first in sorted path order keeps the slug and the later file is reported as a `parse` failure
135
+ - `failurePolicy: "abort"` throws `IndexingError` on the first file that fails
136
+ - `failurePolicy: "skip"` drops the failing file, records it in `failures`, and rebuilds from the survivors, returning `partial: true`
137
+ - under `"skip"`, if every discovered file fails, the rebuild throws instead of replacing a valid index with an empty one
138
+ - an empty source directory throws unless `allowEmptyIndex: true`, which intentionally empties the index
139
+ - `onProgress` is called once per file during the build phase
112
140
  - frontmatter `title`, `description`, and `tags` are folded into the embedding text
113
141
  - embeddings default to `intent: "document"` unless `embeddingOptions.intent` is set explicitly
114
142
  - if a file has no frontmatter title, the filename becomes the title
115
143
 
144
+ ### `IndexingError`
145
+
146
+ Thrown when a rebuild cannot complete. The previously indexed rows are always left unchanged.
147
+
148
+ ```ts
149
+ class IndexingError extends Error {
150
+ readonly phase: "build" | "replace";
151
+ readonly failures: IndexFailure[];
152
+ }
153
+ ```
154
+
155
+ - `phase: "build"` means the failure happened before any database work: a file failed, every file failed, the source directory was empty, or it could not be scanned
156
+ - `phase: "replace"` means the replacement transaction failed and was rolled back
157
+ - `cause` carries the underlying error
158
+ - on a `phase: "replace"` error, `failures` lists files skipped during the build phase. They are not the cause of the rollback, which is carried by `cause`
159
+
160
+ ```ts
161
+ import { indexContent, IndexingError } from "libsql-search";
162
+
163
+ try {
164
+ await indexContent({ client, contentPath: "./content" });
165
+ } catch (error) {
166
+ if (error instanceof IndexingError) {
167
+ console.error(error.phase, error.failures);
168
+ }
169
+
170
+ throw error;
171
+ }
172
+ ```
173
+
174
+ Breaking changes in this behavior:
175
+
176
+ - partial failures previously counted into `failed` and still replaced the table; they now throw. Pass `failurePolicy: "skip"` for the previous lenient behavior.
177
+ - an empty source directory previously returned zeros and left stale rows in place; it now throws. Pass `allowEmptyIndex: true` to intentionally empty the index.
178
+
116
179
  ## `search(options)`
117
180
 
118
181
  Generates a query embedding and performs vector similarity search.
package/docs/INDEXING.md CHANGED
@@ -15,7 +15,7 @@ The slug is derived from the file path relative to `contentPath`.
15
15
 
16
16
  ## Rebuild Behavior
17
17
 
18
- `indexContent()` clears the target table before rebuilding:
18
+ `indexContent()` replaces the whole target table:
19
19
 
20
20
  ```ts
21
21
  await indexContent({
@@ -28,14 +28,89 @@ await indexContent({
28
28
  });
29
29
  ```
30
30
 
31
- That keeps the implementation simple, but it also means:
31
+ The rebuild runs in two phases:
32
32
 
33
- - failed rebuilds can leave the table partially repopulated
34
- - provider or dimension changes should use a parallel table migration
33
+ 1. build: every file is read, parsed, and embedded in memory, touching no database state
34
+ 2. replace: the delete and all inserts run in a single write transaction
35
+
36
+ That means:
37
+
38
+ - a failed rebuild leaves the previously indexed rows exactly as they were
39
+ - provider or dimension changes should still use a parallel table migration
35
40
  - `createTable()` does not resize an existing vector column
36
41
 
42
+ Files are discovered and indexed in sorted path order, so a rebuild is deterministic.
43
+
37
44
  If provider, dimensions, model, endpoint, or embedding-space assumptions change, fully reindex into a new table. See the canonical [Migration and reindexing guide](./MIGRATIONS.md).
38
45
 
46
+ ### Costs Of The Two-Phase Rebuild
47
+
48
+ Atomicity is not free, and both costs scale with corpus size:
49
+
50
+ - **Peak memory holds the whole corpus.** The build phase keeps every document in memory: content, frontmatter, and one embedding array per document. The replace phase then builds insert statements including a JSON copy of each embedding, roughly 5-8 KB per document at 384 dimensions and considerably more at 3072. Documents are released as their statements are built, but peak usage is still proportional to the entire corpus rather than to one file.
51
+ - **Remote clients send one request.** Against Turso or any remote client, the delete and every insert travel as a single batch. There is no chunking fallback, because splitting the batch would give up the atomicity this design exists to provide. A corpus large enough to exceed a remote request-size limit fails as an opaque `phase: "replace"` error.
52
+
53
+ For very large corpora, index into a parallel table and switch reads over once it validates, rather than rebuilding a live table in place. See the [Migration and reindexing guide](./MIGRATIONS.md).
54
+
55
+ ## Content Requirements
56
+
57
+ Two authoring mistakes fail a file at the `parse` stage rather than corrupting the rebuild:
58
+
59
+ - **Frontmatter `title` must be a scalar.** Strings, numbers, booleans, and dates are accepted; dates are stored as ISO strings. A structured title such as a YAML list fails the file. A missing or empty title still falls back to the filename.
60
+ - **Slugs must be unique.** The slug comes from the path with the extension removed, so `foo.md` and `foo.markdown` collide. Files are processed in sorted path order and the first file to claim a slug keeps it, so `foo.markdown` wins and `foo.md` is reported as the failure.
61
+
62
+ Both are governed by `failurePolicy` like any other build failure, so they abort by default and are skippable.
63
+
64
+ ## Failure Handling
65
+
66
+ `indexContent()` throws `IndexingError` instead of reporting a partially applied rebuild. The error carries `phase` (`"build"` or `"replace"`), a `failures` array, and the underlying error as `cause`.
67
+
68
+ ```ts
69
+ import { indexContent, IndexingError } from "libsql-search";
70
+
71
+ try {
72
+ await indexContent({ client, contentPath: "./content" });
73
+ } catch (error) {
74
+ if (error instanceof IndexingError) {
75
+ for (const failure of error.failures) {
76
+ console.error(`${failure.file} failed during ${failure.stage}`);
77
+ }
78
+ }
79
+
80
+ throw error;
81
+ }
82
+ ```
83
+
84
+ By default one bad file aborts the whole rebuild. To index everything that can be indexed, opt into `failurePolicy: "skip"`:
85
+
86
+ ```ts
87
+ const result = await indexContent({
88
+ client,
89
+ contentPath: "./content",
90
+ failurePolicy: "skip",
91
+ });
92
+
93
+ if (result.partial) {
94
+ console.warn(`Indexed ${result.success} of ${result.total} files`);
95
+ }
96
+ ```
97
+
98
+ Skipped rebuilds still replace the table, so treat `partial: true` as a build warning rather than a clean rebuild. If every discovered file fails, the rebuild throws rather than trading a valid index for an empty one.
99
+
100
+ ## Empty Source Directories
101
+
102
+ An empty source directory throws by default, because silently leaving stale rows in place serves search traffic from content that no longer exists. Emptying an index has to be intentional:
103
+
104
+ ```ts
105
+ await indexContent({
106
+ client,
107
+ contentPath: "./content",
108
+ allowEmptyIndex: true,
109
+ });
110
+ ```
111
+
112
+ Both behaviors changed in a breaking way: partial failures used to be counted and reported, and an empty directory used to return zeros without clearing the table.
113
+
39
114
  ## Quality Guidelines
40
115
 
41
116
  - include descriptive frontmatter titles
@@ -22,7 +22,7 @@ References:
22
22
  - if dimensions change, create a new table or recreate the old table, then fully re-embed
23
23
  - if dimensions stay the same but provider, model, endpoint, model revision, pooling, normalization, or input formatting changes, fully reindex anyway
24
24
  - never mix two embedding spaces in one table
25
- - prefer a parallel table migration because `indexContent()` clears rows first and is not transactional
25
+ - prefer a parallel table migration because `indexContent()` replaces the whole target table, so an in-place rebuild leaves no way back to the old vectors
26
26
 
27
27
  In practice, this means:
28
28
 
@@ -39,6 +39,8 @@ In practice, this means:
39
39
  5. Switch application reads and writes to the new table.
40
40
  6. Retire the old table in a separate cleanup step.
41
41
 
42
+ Step 3 is all or nothing. `indexContent()` throws `IndexingError` and leaves the target table untouched when a file or the replacement transaction fails, so a failed migration step can be retried without cleanup. See [Indexing and operational behavior](./INDEXING.md) for `failurePolicy` and `allowEmptyIndex`.
43
+
42
44
  Example:
43
45
 
44
46
  ```ts
package/docs/RELEASING.md CHANGED
@@ -19,11 +19,19 @@ and JSR, then creates GitHub Release notes after both registries succeed.
19
19
  `.github/**` do not count as release-eligible and do not affect the bump. If
20
20
  the newest `main` commit is docs-only but an earlier untagged code or README
21
21
  commit is still pending, that newest run releases the accumulated eligible
22
- changes. Breaking changes bump major, `feat:` bumps minor, and all other
23
- eligible commits bump patch.
22
+ changes. `feat:` bumps minor and all other eligible commits bump patch.
23
+ Breaking changes (a `subject!:` prefix or a `BREAKING CHANGE:` footer) bump
24
+ major only once the package is `1.0.0` or higher. While the package is still
25
+ on the `0.x` line a breaking change bumps the minor instead, so an unattended
26
+ `fix!:` cannot auto-promote the package to `1.0.0`. Promoting off `0.x` is
27
+ deliberate and manual: see step 5.
24
28
  5. `package.json`, `jsr.json`, and `deno.json` are synchronized to the chosen
25
29
  version. If they already match the chosen version, no release commit is
26
- created.
30
+ created. A manifest version that is already ahead of the latest tag wins over
31
+ the computed bump; this is the supported escape hatch for a deliberate
32
+ version jump, including promoting off the `0.x` line. Set all three manifests
33
+ to `1.0.0` on a reviewed PR and the next qualifying release publishes
34
+ `v1.0.0`.
27
35
  6. The workflow validates the candidate, creates an annotated tag, and atomically
28
36
  pushes the release commit plus tag.
29
37
  7. npm publishes `libsql-search` through trusted publishing OIDC with the GitHub
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libsql-search",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Semantic search for static sites using libSQL/Turso with multi-provider embeddings",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.34.5",