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 +3 -1
- package/dist/index.cjs +184 -31
- package/dist/index.d.ts +56 -8
- package/dist/index.esm.js +184 -32
- package/docs/API.md +69 -6
- package/docs/INDEXING.md +79 -4
- package/docs/MIGRATIONS.md +3 -1
- package/docs/RELEASING.md +11 -3
- package/package.json +1 -1
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()`
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
|
|
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
|
|
874
|
-
|
|
875
|
-
|
|
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
|
|
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
|
-
|
|
996
|
+
serializedTags,
|
|
997
|
+
embeddingText,
|
|
896
998
|
metadata: frontMatter
|
|
897
999
|
};
|
|
898
1000
|
}
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
143
|
+
* Raised when a rebuild cannot complete. The previously indexed rows are always
|
|
144
|
+
* left exactly as they were.
|
|
105
145
|
*/
|
|
106
|
-
declare
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
|
872
|
-
|
|
873
|
-
|
|
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
|
|
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
|
-
|
|
994
|
+
serializedTags,
|
|
995
|
+
embeddingText,
|
|
894
996
|
metadata: frontMatter
|
|
895
997
|
};
|
|
896
998
|
}
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
111
|
-
-
|
|
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()`
|
|
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
|
-
|
|
31
|
+
The rebuild runs in two phases:
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
package/docs/MIGRATIONS.md
CHANGED
|
@@ -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()`
|
|
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.
|
|
23
|
-
|
|
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
|