libsql-search 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -114,6 +114,34 @@ function prepareTextForEmbedding(fields) {
114
114
  return parts.filter(Boolean).join("\n\n");
115
115
  }
116
116
 
117
+ const SQL_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
118
+ const DEFAULT_SEARCH_LIMIT = 10;
119
+ const MAX_SEARCH_LIMIT = 100;
120
+ function validateSqlIdentifier(identifier, name = "identifier") {
121
+ if (typeof identifier !== "string" || !SQL_IDENTIFIER_PATTERN.test(identifier)) {
122
+ throw new Error(
123
+ `Invalid SQL ${name}: expected an ASCII identifier matching ${SQL_IDENTIFIER_PATTERN.toString()}`
124
+ );
125
+ }
126
+ return identifier;
127
+ }
128
+ function quoteSqlIdentifier(identifier, name) {
129
+ const validated = validateSqlIdentifier(identifier, name);
130
+ return `"${validated.replace(/"/g, '""')}"`;
131
+ }
132
+ function normalizeSearchLimit(limit = DEFAULT_SEARCH_LIMIT) {
133
+ if (typeof limit !== "number" || !Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_LIMIT) {
134
+ throw new Error(`Invalid search limit: expected an integer from 1 to ${MAX_SEARCH_LIMIT}`);
135
+ }
136
+ return limit;
137
+ }
138
+ function normalizeVectorDimensions(dimensions) {
139
+ if (typeof dimensions !== "number" || !Number.isFinite(dimensions) || !Number.isInteger(dimensions) || dimensions < 1) {
140
+ throw new Error("Invalid vector dimensions: expected a positive integer");
141
+ }
142
+ return dimensions;
143
+ }
144
+
117
145
  async function indexContent(options) {
118
146
  const {
119
147
  client,
@@ -124,12 +152,13 @@ async function indexContent(options) {
124
152
  tableName = "articles",
125
153
  onProgress
126
154
  } = options;
155
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
127
156
  const files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
128
157
  if (files.length === 0) {
129
158
  console.warn(`No files found in ${contentPath}`);
130
159
  return { success: 0, failed: 0, total: 0 };
131
160
  }
132
- await client.execute(`DELETE FROM ${tableName}`);
161
+ await client.execute(`DELETE FROM ${quotedTableName}`);
133
162
  let success = 0;
134
163
  let failed = 0;
135
164
  for (let i = 0; i < files.length; i++) {
@@ -139,7 +168,7 @@ async function indexContent(options) {
139
168
  }
140
169
  try {
141
170
  const document = await processFile(file, embeddingOptions);
142
- await insertDocument(client, document, tableName);
171
+ await insertDocument(client, document, quotedTableName);
143
172
  success++;
144
173
  } catch (error) {
145
174
  console.error(`Failed to index ${file.relativePath}:`, error);
@@ -193,9 +222,9 @@ async function processFile(file, embeddingOptions) {
193
222
  metadata: frontMatter
194
223
  };
195
224
  }
196
- async function insertDocument(client, document, tableName) {
225
+ async function insertDocument(client, document, quotedTableName) {
197
226
  await client.execute({
198
- sql: `INSERT INTO ${tableName}
227
+ sql: `INSERT INTO ${quotedTableName}
199
228
  (slug, title, content, folder, tags, embedding, created_at, updated_at)
200
229
  VALUES (?, ?, ?, ?, ?, vector(?), datetime('now'), datetime('now'))`,
201
230
  args: [
@@ -209,30 +238,35 @@ async function insertDocument(client, document, tableName) {
209
238
  });
210
239
  }
211
240
  async function createTable(client, tableName = "articles", dimensions = 768) {
241
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
242
+ const vectorDimensions = normalizeVectorDimensions(dimensions);
243
+ const quotedEmbeddingIndexName = quoteSqlIdentifier(`${tableName}_embedding_idx`, "embedding index name");
244
+ const quotedFolderIndexName = quoteSqlIdentifier(`${tableName}_folder_idx`, "folder index name");
245
+ const quotedSlugIndexName = quoteSqlIdentifier(`${tableName}_slug_idx`, "slug index name");
212
246
  await client.execute(`
213
- CREATE TABLE IF NOT EXISTS ${tableName} (
247
+ CREATE TABLE IF NOT EXISTS ${quotedTableName} (
214
248
  id INTEGER PRIMARY KEY AUTOINCREMENT,
215
249
  slug TEXT UNIQUE NOT NULL,
216
250
  title TEXT NOT NULL,
217
251
  content TEXT NOT NULL,
218
252
  folder TEXT NOT NULL DEFAULT 'root',
219
253
  tags TEXT DEFAULT '[]',
220
- embedding F32_BLOB(${dimensions}),
254
+ embedding F32_BLOB(${vectorDimensions}),
221
255
  created_at TEXT NOT NULL,
222
256
  updated_at TEXT NOT NULL
223
257
  )
224
258
  `);
225
259
  await client.execute(`
226
- CREATE INDEX IF NOT EXISTS ${tableName}_embedding_idx
227
- ON ${tableName}(libsql_vector_idx(embedding))
260
+ CREATE INDEX IF NOT EXISTS ${quotedEmbeddingIndexName}
261
+ ON ${quotedTableName}(libsql_vector_idx(embedding))
228
262
  `);
229
263
  await client.execute(`
230
- CREATE INDEX IF NOT EXISTS ${tableName}_folder_idx
231
- ON ${tableName}(folder)
264
+ CREATE INDEX IF NOT EXISTS ${quotedFolderIndexName}
265
+ ON ${quotedTableName}(folder)
232
266
  `);
233
267
  await client.execute(`
234
- CREATE INDEX IF NOT EXISTS ${tableName}_slug_idx
235
- ON ${tableName}(slug)
268
+ CREATE INDEX IF NOT EXISTS ${quotedSlugIndexName}
269
+ ON ${quotedTableName}(slug)
236
270
  `);
237
271
  }
238
272
 
@@ -244,6 +278,8 @@ async function search(options) {
244
278
  tableName = "articles",
245
279
  embeddingOptions = {}
246
280
  } = options;
281
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
282
+ const resultLimit = normalizeSearchLimit(limit);
247
283
  const queryEmbedding = await generateEmbedding(query, embeddingOptions);
248
284
  const results = await client.execute({
249
285
  sql: `
@@ -256,12 +292,12 @@ async function search(options) {
256
292
  tags,
257
293
  created_at,
258
294
  vector_distance_cos(embedding, vector(?)) as distance
259
- FROM ${tableName}
295
+ FROM ${quotedTableName}
260
296
  WHERE embedding IS NOT NULL
261
297
  ORDER BY distance
262
298
  LIMIT ?
263
299
  `,
264
- args: [JSON.stringify(queryEmbedding), limit]
300
+ args: [JSON.stringify(queryEmbedding), resultLimit]
265
301
  });
266
302
  return results.rows.map((row) => ({
267
303
  id: row.id,
@@ -275,9 +311,10 @@ async function search(options) {
275
311
  }));
276
312
  }
277
313
  async function getAllArticles(client, tableName = "articles") {
314
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
278
315
  const results = await client.execute(`
279
316
  SELECT id, slug, title, folder, tags, created_at, updated_at
280
- FROM ${tableName}
317
+ FROM ${quotedTableName}
281
318
  ORDER BY title
282
319
  `);
283
320
  return results.rows.map((row) => ({
@@ -291,10 +328,11 @@ async function getAllArticles(client, tableName = "articles") {
291
328
  }));
292
329
  }
293
330
  async function getArticleBySlug(client, slug, tableName = "articles") {
331
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
294
332
  const results = await client.execute({
295
333
  sql: `
296
334
  SELECT id, slug, title, content, folder, tags, created_at, updated_at
297
- FROM ${tableName}
335
+ FROM ${quotedTableName}
298
336
  WHERE slug = ?
299
337
  LIMIT 1
300
338
  `,
@@ -316,10 +354,11 @@ async function getArticleBySlug(client, slug, tableName = "articles") {
316
354
  };
317
355
  }
318
356
  async function getArticlesByFolder(client, folder, tableName = "articles") {
357
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
319
358
  const results = await client.execute({
320
359
  sql: `
321
360
  SELECT id, slug, title, folder, tags
322
- FROM ${tableName}
361
+ FROM ${quotedTableName}
323
362
  WHERE folder = ?
324
363
  ORDER BY title
325
364
  `,
@@ -334,9 +373,10 @@ async function getArticlesByFolder(client, folder, tableName = "articles") {
334
373
  }));
335
374
  }
336
375
  async function getFolders(client, tableName = "articles") {
376
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
337
377
  const results = await client.execute(`
338
378
  SELECT DISTINCT folder
339
- FROM ${tableName}
379
+ FROM ${quotedTableName}
340
380
  ORDER BY folder
341
381
  `);
342
382
  return results.rows.map((row) => row.folder);
package/dist/index.esm.js CHANGED
@@ -112,6 +112,34 @@ function prepareTextForEmbedding(fields) {
112
112
  return parts.filter(Boolean).join("\n\n");
113
113
  }
114
114
 
115
+ const SQL_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
116
+ const DEFAULT_SEARCH_LIMIT = 10;
117
+ const MAX_SEARCH_LIMIT = 100;
118
+ function validateSqlIdentifier(identifier, name = "identifier") {
119
+ if (typeof identifier !== "string" || !SQL_IDENTIFIER_PATTERN.test(identifier)) {
120
+ throw new Error(
121
+ `Invalid SQL ${name}: expected an ASCII identifier matching ${SQL_IDENTIFIER_PATTERN.toString()}`
122
+ );
123
+ }
124
+ return identifier;
125
+ }
126
+ function quoteSqlIdentifier(identifier, name) {
127
+ const validated = validateSqlIdentifier(identifier, name);
128
+ return `"${validated.replace(/"/g, '""')}"`;
129
+ }
130
+ function normalizeSearchLimit(limit = DEFAULT_SEARCH_LIMIT) {
131
+ if (typeof limit !== "number" || !Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_LIMIT) {
132
+ throw new Error(`Invalid search limit: expected an integer from 1 to ${MAX_SEARCH_LIMIT}`);
133
+ }
134
+ return limit;
135
+ }
136
+ function normalizeVectorDimensions(dimensions) {
137
+ if (typeof dimensions !== "number" || !Number.isFinite(dimensions) || !Number.isInteger(dimensions) || dimensions < 1) {
138
+ throw new Error("Invalid vector dimensions: expected a positive integer");
139
+ }
140
+ return dimensions;
141
+ }
142
+
115
143
  async function indexContent(options) {
116
144
  const {
117
145
  client,
@@ -122,12 +150,13 @@ async function indexContent(options) {
122
150
  tableName = "articles",
123
151
  onProgress
124
152
  } = options;
153
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
125
154
  const files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
126
155
  if (files.length === 0) {
127
156
  console.warn(`No files found in ${contentPath}`);
128
157
  return { success: 0, failed: 0, total: 0 };
129
158
  }
130
- await client.execute(`DELETE FROM ${tableName}`);
159
+ await client.execute(`DELETE FROM ${quotedTableName}`);
131
160
  let success = 0;
132
161
  let failed = 0;
133
162
  for (let i = 0; i < files.length; i++) {
@@ -137,7 +166,7 @@ async function indexContent(options) {
137
166
  }
138
167
  try {
139
168
  const document = await processFile(file, embeddingOptions);
140
- await insertDocument(client, document, tableName);
169
+ await insertDocument(client, document, quotedTableName);
141
170
  success++;
142
171
  } catch (error) {
143
172
  console.error(`Failed to index ${file.relativePath}:`, error);
@@ -191,9 +220,9 @@ async function processFile(file, embeddingOptions) {
191
220
  metadata: frontMatter
192
221
  };
193
222
  }
194
- async function insertDocument(client, document, tableName) {
223
+ async function insertDocument(client, document, quotedTableName) {
195
224
  await client.execute({
196
- sql: `INSERT INTO ${tableName}
225
+ sql: `INSERT INTO ${quotedTableName}
197
226
  (slug, title, content, folder, tags, embedding, created_at, updated_at)
198
227
  VALUES (?, ?, ?, ?, ?, vector(?), datetime('now'), datetime('now'))`,
199
228
  args: [
@@ -207,30 +236,35 @@ async function insertDocument(client, document, tableName) {
207
236
  });
208
237
  }
209
238
  async function createTable(client, tableName = "articles", dimensions = 768) {
239
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
240
+ const vectorDimensions = normalizeVectorDimensions(dimensions);
241
+ const quotedEmbeddingIndexName = quoteSqlIdentifier(`${tableName}_embedding_idx`, "embedding index name");
242
+ const quotedFolderIndexName = quoteSqlIdentifier(`${tableName}_folder_idx`, "folder index name");
243
+ const quotedSlugIndexName = quoteSqlIdentifier(`${tableName}_slug_idx`, "slug index name");
210
244
  await client.execute(`
211
- CREATE TABLE IF NOT EXISTS ${tableName} (
245
+ CREATE TABLE IF NOT EXISTS ${quotedTableName} (
212
246
  id INTEGER PRIMARY KEY AUTOINCREMENT,
213
247
  slug TEXT UNIQUE NOT NULL,
214
248
  title TEXT NOT NULL,
215
249
  content TEXT NOT NULL,
216
250
  folder TEXT NOT NULL DEFAULT 'root',
217
251
  tags TEXT DEFAULT '[]',
218
- embedding F32_BLOB(${dimensions}),
252
+ embedding F32_BLOB(${vectorDimensions}),
219
253
  created_at TEXT NOT NULL,
220
254
  updated_at TEXT NOT NULL
221
255
  )
222
256
  `);
223
257
  await client.execute(`
224
- CREATE INDEX IF NOT EXISTS ${tableName}_embedding_idx
225
- ON ${tableName}(libsql_vector_idx(embedding))
258
+ CREATE INDEX IF NOT EXISTS ${quotedEmbeddingIndexName}
259
+ ON ${quotedTableName}(libsql_vector_idx(embedding))
226
260
  `);
227
261
  await client.execute(`
228
- CREATE INDEX IF NOT EXISTS ${tableName}_folder_idx
229
- ON ${tableName}(folder)
262
+ CREATE INDEX IF NOT EXISTS ${quotedFolderIndexName}
263
+ ON ${quotedTableName}(folder)
230
264
  `);
231
265
  await client.execute(`
232
- CREATE INDEX IF NOT EXISTS ${tableName}_slug_idx
233
- ON ${tableName}(slug)
266
+ CREATE INDEX IF NOT EXISTS ${quotedSlugIndexName}
267
+ ON ${quotedTableName}(slug)
234
268
  `);
235
269
  }
236
270
 
@@ -242,6 +276,8 @@ async function search(options) {
242
276
  tableName = "articles",
243
277
  embeddingOptions = {}
244
278
  } = options;
279
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
280
+ const resultLimit = normalizeSearchLimit(limit);
245
281
  const queryEmbedding = await generateEmbedding(query, embeddingOptions);
246
282
  const results = await client.execute({
247
283
  sql: `
@@ -254,12 +290,12 @@ async function search(options) {
254
290
  tags,
255
291
  created_at,
256
292
  vector_distance_cos(embedding, vector(?)) as distance
257
- FROM ${tableName}
293
+ FROM ${quotedTableName}
258
294
  WHERE embedding IS NOT NULL
259
295
  ORDER BY distance
260
296
  LIMIT ?
261
297
  `,
262
- args: [JSON.stringify(queryEmbedding), limit]
298
+ args: [JSON.stringify(queryEmbedding), resultLimit]
263
299
  });
264
300
  return results.rows.map((row) => ({
265
301
  id: row.id,
@@ -273,9 +309,10 @@ async function search(options) {
273
309
  }));
274
310
  }
275
311
  async function getAllArticles(client, tableName = "articles") {
312
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
276
313
  const results = await client.execute(`
277
314
  SELECT id, slug, title, folder, tags, created_at, updated_at
278
- FROM ${tableName}
315
+ FROM ${quotedTableName}
279
316
  ORDER BY title
280
317
  `);
281
318
  return results.rows.map((row) => ({
@@ -289,10 +326,11 @@ async function getAllArticles(client, tableName = "articles") {
289
326
  }));
290
327
  }
291
328
  async function getArticleBySlug(client, slug, tableName = "articles") {
329
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
292
330
  const results = await client.execute({
293
331
  sql: `
294
332
  SELECT id, slug, title, content, folder, tags, created_at, updated_at
295
- FROM ${tableName}
333
+ FROM ${quotedTableName}
296
334
  WHERE slug = ?
297
335
  LIMIT 1
298
336
  `,
@@ -314,10 +352,11 @@ async function getArticleBySlug(client, slug, tableName = "articles") {
314
352
  };
315
353
  }
316
354
  async function getArticlesByFolder(client, folder, tableName = "articles") {
355
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
317
356
  const results = await client.execute({
318
357
  sql: `
319
358
  SELECT id, slug, title, folder, tags
320
- FROM ${tableName}
359
+ FROM ${quotedTableName}
321
360
  WHERE folder = ?
322
361
  ORDER BY title
323
362
  `,
@@ -332,9 +371,10 @@ async function getArticlesByFolder(client, folder, tableName = "articles") {
332
371
  }));
333
372
  }
334
373
  async function getFolders(client, tableName = "articles") {
374
+ const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
335
375
  const results = await client.execute(`
336
376
  SELECT DISTINCT folder
337
- FROM ${tableName}
377
+ FROM ${quotedTableName}
338
378
  ORDER BY folder
339
379
  `);
340
380
  return results.rows.map((row) => row.folder);
package/docs/API.md CHANGED
@@ -37,6 +37,11 @@ Defaults:
37
37
  - `tableName`: `"articles"`
38
38
  - `dimensions`: `768`
39
39
 
40
+ `tableName` must be an ASCII SQLite identifier matching
41
+ `[A-Za-z_][A-Za-z0-9_]*`. Valid identifiers are quoted internally, so reserved
42
+ words such as `"select"` are safe to use. `dimensions` must be a positive
43
+ integer.
44
+
40
45
  The created schema includes:
41
46
 
42
47
  - `id` primary key
@@ -71,6 +76,8 @@ Defaults:
71
76
  - `exclude`: ["node_modules", ".git", "dist", "build"]
72
77
  - `tableName`: `"articles"`
73
78
 
79
+ `tableName` follows the same identifier policy as `createTable()`.
80
+
74
81
  Return shape:
75
82
 
76
83
  ```ts
@@ -107,6 +114,10 @@ Defaults:
107
114
  - `limit`: `10`
108
115
  - `tableName`: `"articles"`
109
116
 
117
+ `limit` must be an integer from `1` through `100`; invalid values are rejected
118
+ before query embedding generation. `tableName` follows the same identifier
119
+ policy as `createTable()`.
120
+
110
121
  Result shape:
111
122
 
112
123
  ```ts
@@ -142,6 +153,8 @@ Returns articles in a specific folder.
142
153
 
143
154
  Returns distinct folder names from the index.
144
155
 
156
+ All article retrieval helpers validate `tableName` before executing SQL.
157
+
145
158
  ## Embedding Helpers
146
159
 
147
160
  ### `generateEmbedding(text, options?)`
package/docs/INDEXING.md CHANGED
@@ -57,8 +57,10 @@ site build:
57
57
 
58
58
  ## Table Names
59
59
 
60
- `tableName` is interpolated into SQL. Treat it as a trusted identifier coming
61
- from your own configuration, not from user input.
60
+ `tableName` must be an ASCII SQLite identifier matching
61
+ `[A-Za-z_][A-Za-z0-9_]*`. Valid names are quoted internally for table and index
62
+ SQL, so reserved words such as `"select"` work safely. Invalid names fail before
63
+ database calls or embedding generation.
62
64
 
63
65
  ## Runtime Notes
64
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libsql-search",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",