libsql-search 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/turso.cjs CHANGED
@@ -1,8 +1,65 @@
1
1
  'use strict';
2
2
 
3
+ const MAX_QUERY_STATEMENT_CACHE_SIZE = 32;
3
4
  function tursoAdapter(database) {
4
5
  assertTursoDatabase(database);
5
- return {
6
+ const queryStatementsBySql = /* @__PURE__ */ new Map();
7
+ const liveQueryStatements = /* @__PURE__ */ new Set();
8
+ let disposed = false;
9
+ let disposePromise;
10
+ const assertUsable = () => {
11
+ if (disposed) {
12
+ throw new Error("This Turso adapter has been disposed");
13
+ }
14
+ };
15
+ const closeQueryStatement = (entry) => {
16
+ entry.closePromise ??= closeStatement(entry.statement).finally(() => {
17
+ liveQueryStatements.delete(entry);
18
+ });
19
+ return entry.closePromise;
20
+ };
21
+ const retireQueryStatement = (entry) => {
22
+ if (queryStatementsBySql.get(entry.sql) === entry) {
23
+ queryStatementsBySql.delete(entry.sql);
24
+ }
25
+ entry.retired = true;
26
+ if (entry.pending === 0) {
27
+ void closeQueryStatement(entry);
28
+ }
29
+ };
30
+ const acquireQueryStatement = (sql) => {
31
+ assertUsable();
32
+ let entry = queryStatementsBySql.get(sql);
33
+ if (entry === void 0) {
34
+ entry = {
35
+ sql,
36
+ statement: database.prepare(sql),
37
+ tail: Promise.resolve(),
38
+ pending: 0,
39
+ retired: false
40
+ };
41
+ queryStatementsBySql.set(sql, entry);
42
+ liveQueryStatements.add(entry);
43
+ if (queryStatementsBySql.size > MAX_QUERY_STATEMENT_CACHE_SIZE) {
44
+ const oldest = queryStatementsBySql.values().next().value;
45
+ if (oldest !== void 0) {
46
+ retireQueryStatement(oldest);
47
+ }
48
+ }
49
+ } else {
50
+ queryStatementsBySql.delete(sql);
51
+ queryStatementsBySql.set(sql, entry);
52
+ }
53
+ entry.pending += 1;
54
+ const turn = entry.tail;
55
+ let release;
56
+ const completion = new Promise((resolve) => {
57
+ release = resolve;
58
+ });
59
+ entry.tail = turn.then(() => completion);
60
+ return { entry, turn, release };
61
+ };
62
+ const adapter = {
6
63
  libsqlSearchAdapter: true,
7
64
  backend: "turso",
8
65
  /**
@@ -12,15 +69,24 @@ function tursoAdapter(database) {
12
69
  */
13
70
  supportsVectorIndex: false,
14
71
  async executeDdl(sql) {
72
+ assertUsable();
15
73
  await database.exec(sql);
16
74
  },
17
75
  async executeQuery(sql, args) {
18
- const statement = database.prepare(sql);
76
+ const { entry, turn, release } = acquireQueryStatement(sql);
77
+ await turn;
19
78
  try {
20
- const rows = await (args === void 0 ? statement.all() : statement.all(args));
79
+ const rows = await (args === void 0 ? entry.statement.all() : entry.statement.all(args));
21
80
  return rows;
81
+ } catch (error) {
82
+ retireQueryStatement(entry);
83
+ throw error;
22
84
  } finally {
23
- closeStatement(statement);
85
+ entry.pending -= 1;
86
+ release();
87
+ if (entry.retired && entry.pending === 0) {
88
+ await closeQueryStatement(entry);
89
+ }
24
90
  }
25
91
  },
26
92
  /**
@@ -37,6 +103,7 @@ function tursoAdapter(database) {
37
103
  * this turns N+1 prepares into exactly 2 regardless of corpus size.
38
104
  */
39
105
  async executeAtomicWrite(statements) {
106
+ assertUsable();
40
107
  const preparedBySql = /* @__PURE__ */ new Map();
41
108
  const prepareOnce = (sql) => {
42
109
  let prepared = preparedBySql.get(sql);
@@ -62,16 +129,32 @@ function tursoAdapter(database) {
62
129
  throw error;
63
130
  } finally {
64
131
  for (const prepared of preparedBySql.values()) {
65
- closeStatement(prepared);
132
+ await closeStatement(prepared);
66
133
  }
67
134
  preparedBySql.clear();
68
135
  }
136
+ },
137
+ dispose() {
138
+ disposePromise ??= (async () => {
139
+ disposed = true;
140
+ for (const entry of [...queryStatementsBySql.values()]) {
141
+ retireQueryStatement(entry);
142
+ }
143
+ await Promise.all(
144
+ [...liveQueryStatements].map(async (entry) => {
145
+ await entry.tail;
146
+ await closeQueryStatement(entry);
147
+ })
148
+ );
149
+ })();
150
+ return disposePromise;
69
151
  }
70
152
  };
153
+ return adapter;
71
154
  }
72
- function closeStatement(statement) {
155
+ async function closeStatement(statement) {
73
156
  try {
74
- statement.close?.();
157
+ await statement.close?.();
75
158
  } catch {
76
159
  }
77
160
  }
package/dist/turso.d.ts CHANGED
@@ -86,11 +86,21 @@ interface DatabaseAdapter {
86
86
  * import { tursoAdapter } from 'libsql-search/turso';
87
87
  * import { createTable, indexContent, search } from 'libsql-search';
88
88
  *
89
- * const client = tursoAdapter(await connect('./local.db'));
89
+ * const database = await connect('./local.db');
90
+ * const client = tursoAdapter(database);
91
+ * const embeddingOptions = {
92
+ * provider: 'openai-compatible' as const,
93
+ * baseUrl: 'https://embeddings.example.com/v1',
94
+ * model: 'bge-large-en-v1.5',
95
+ * dimensions: 1024
96
+ * };
90
97
  *
91
- * await createTable(client);
92
- * await indexContent({ client, contentPath: './content' });
93
- * const results = await search({ client, query: 'vector search' });
98
+ * await createTable(client, 'articles', 1024);
99
+ * await indexContent({ client, contentPath: './content', embeddingOptions });
100
+ * const results = await search({ client, query: 'vector search', embeddingOptions });
101
+ *
102
+ * await client.dispose();
103
+ * await database.close();
94
104
  * ```
95
105
  *
96
106
  * @module libsql-search/turso
@@ -113,8 +123,9 @@ interface TursoStatement {
113
123
  *
114
124
  * Not closing leaks roughly 10 KB of native memory per prepare on
115
125
  * `@tursodatabase/database`, which the garbage collector does not reclaim
116
- * because it is not JavaScript heap. A server calling `search()` per request
117
- * grows without bound until the process is killed.
126
+ * because it is not JavaScript heap. The adapter therefore closes
127
+ * transaction-local statements immediately and query statements on safe LRU
128
+ * eviction or disposal.
118
129
  */
119
130
  close?(): unknown;
120
131
  }
@@ -130,6 +141,16 @@ interface TursoDatabase {
130
141
  exec(sql: string): unknown;
131
142
  prepare(sql: string): TursoStatement;
132
143
  }
144
+ /**
145
+ * A Turso-backed adapter with an explicit prepared-statement disposal hook.
146
+ *
147
+ * `dispose()` is terminal: it waits for queued query calls, closes every
148
+ * cached query statement, and rejects later adapter operations. It does not
149
+ * close the caller-owned {@link TursoDatabase} handle.
150
+ */
151
+ interface TursoAdapter extends DatabaseAdapter {
152
+ dispose(): Promise<void>;
153
+ }
133
154
  /**
134
155
  * Wrap a `@tursodatabase/database` handle so this library's functions can use
135
156
  * it.
@@ -150,7 +171,7 @@ interface TursoDatabase {
150
171
  * This preserves the guarantee that a failed rebuild leaves the previous
151
172
  * index intact.
152
173
  */
153
- declare function tursoAdapter(database: TursoDatabase): DatabaseAdapter;
174
+ declare function tursoAdapter(database: TursoDatabase): TursoAdapter;
154
175
 
155
176
  export { tursoAdapter };
156
- export type { DatabaseAdapter, TursoDatabase, TursoStatement };
177
+ export type { DatabaseAdapter, TursoAdapter, TursoDatabase, TursoStatement };
package/dist/turso.esm.js CHANGED
@@ -1,6 +1,63 @@
1
+ const MAX_QUERY_STATEMENT_CACHE_SIZE = 32;
1
2
  function tursoAdapter(database) {
2
3
  assertTursoDatabase(database);
3
- return {
4
+ const queryStatementsBySql = /* @__PURE__ */ new Map();
5
+ const liveQueryStatements = /* @__PURE__ */ new Set();
6
+ let disposed = false;
7
+ let disposePromise;
8
+ const assertUsable = () => {
9
+ if (disposed) {
10
+ throw new Error("This Turso adapter has been disposed");
11
+ }
12
+ };
13
+ const closeQueryStatement = (entry) => {
14
+ entry.closePromise ??= closeStatement(entry.statement).finally(() => {
15
+ liveQueryStatements.delete(entry);
16
+ });
17
+ return entry.closePromise;
18
+ };
19
+ const retireQueryStatement = (entry) => {
20
+ if (queryStatementsBySql.get(entry.sql) === entry) {
21
+ queryStatementsBySql.delete(entry.sql);
22
+ }
23
+ entry.retired = true;
24
+ if (entry.pending === 0) {
25
+ void closeQueryStatement(entry);
26
+ }
27
+ };
28
+ const acquireQueryStatement = (sql) => {
29
+ assertUsable();
30
+ let entry = queryStatementsBySql.get(sql);
31
+ if (entry === void 0) {
32
+ entry = {
33
+ sql,
34
+ statement: database.prepare(sql),
35
+ tail: Promise.resolve(),
36
+ pending: 0,
37
+ retired: false
38
+ };
39
+ queryStatementsBySql.set(sql, entry);
40
+ liveQueryStatements.add(entry);
41
+ if (queryStatementsBySql.size > MAX_QUERY_STATEMENT_CACHE_SIZE) {
42
+ const oldest = queryStatementsBySql.values().next().value;
43
+ if (oldest !== void 0) {
44
+ retireQueryStatement(oldest);
45
+ }
46
+ }
47
+ } else {
48
+ queryStatementsBySql.delete(sql);
49
+ queryStatementsBySql.set(sql, entry);
50
+ }
51
+ entry.pending += 1;
52
+ const turn = entry.tail;
53
+ let release;
54
+ const completion = new Promise((resolve) => {
55
+ release = resolve;
56
+ });
57
+ entry.tail = turn.then(() => completion);
58
+ return { entry, turn, release };
59
+ };
60
+ const adapter = {
4
61
  libsqlSearchAdapter: true,
5
62
  backend: "turso",
6
63
  /**
@@ -10,15 +67,24 @@ function tursoAdapter(database) {
10
67
  */
11
68
  supportsVectorIndex: false,
12
69
  async executeDdl(sql) {
70
+ assertUsable();
13
71
  await database.exec(sql);
14
72
  },
15
73
  async executeQuery(sql, args) {
16
- const statement = database.prepare(sql);
74
+ const { entry, turn, release } = acquireQueryStatement(sql);
75
+ await turn;
17
76
  try {
18
- const rows = await (args === void 0 ? statement.all() : statement.all(args));
77
+ const rows = await (args === void 0 ? entry.statement.all() : entry.statement.all(args));
19
78
  return rows;
79
+ } catch (error) {
80
+ retireQueryStatement(entry);
81
+ throw error;
20
82
  } finally {
21
- closeStatement(statement);
83
+ entry.pending -= 1;
84
+ release();
85
+ if (entry.retired && entry.pending === 0) {
86
+ await closeQueryStatement(entry);
87
+ }
22
88
  }
23
89
  },
24
90
  /**
@@ -35,6 +101,7 @@ function tursoAdapter(database) {
35
101
  * this turns N+1 prepares into exactly 2 regardless of corpus size.
36
102
  */
37
103
  async executeAtomicWrite(statements) {
104
+ assertUsable();
38
105
  const preparedBySql = /* @__PURE__ */ new Map();
39
106
  const prepareOnce = (sql) => {
40
107
  let prepared = preparedBySql.get(sql);
@@ -60,16 +127,32 @@ function tursoAdapter(database) {
60
127
  throw error;
61
128
  } finally {
62
129
  for (const prepared of preparedBySql.values()) {
63
- closeStatement(prepared);
130
+ await closeStatement(prepared);
64
131
  }
65
132
  preparedBySql.clear();
66
133
  }
134
+ },
135
+ dispose() {
136
+ disposePromise ??= (async () => {
137
+ disposed = true;
138
+ for (const entry of [...queryStatementsBySql.values()]) {
139
+ retireQueryStatement(entry);
140
+ }
141
+ await Promise.all(
142
+ [...liveQueryStatements].map(async (entry) => {
143
+ await entry.tail;
144
+ await closeQueryStatement(entry);
145
+ })
146
+ );
147
+ })();
148
+ return disposePromise;
67
149
  }
68
150
  };
151
+ return adapter;
69
152
  }
70
- function closeStatement(statement) {
153
+ async function closeStatement(statement) {
71
154
  try {
72
- statement.close?.();
155
+ await statement.close?.();
73
156
  } catch {
74
157
  }
75
158
  }
package/docs/TURSO.md CHANGED
@@ -65,6 +65,11 @@ const results = await search({
65
65
  limit: 5,
66
66
  embeddingOptions,
67
67
  });
68
+
69
+ // When this adapter's lifetime ends, drain and close its cached statements
70
+ // before closing the caller-owned database handle.
71
+ await client.dispose();
72
+ await database.close();
68
73
  ```
69
74
 
70
75
  Use `":memory:"` instead of a file path for an ephemeral database.
@@ -74,6 +79,29 @@ Use `":memory:"` instead of a file path for an ephemeral database.
74
79
  result shapes — `SearchOptions`, `SearchResult`, `IndexerOptions`,
75
80
  `IndexResult` — are identical on both backends.
76
81
 
82
+ ### Adapter lifetime and disposal
83
+
84
+ One adapter caches up to **32 query statements**, keyed by SQL and evicted in
85
+ least-recently-used order. The bound matters because public `tableName` options
86
+ are embedded in SQL: an unbounded cache would let a long-lived process retain a
87
+ new native statement for every valid table name it sees. Statements that are
88
+ currently running or queued are closed after they finish rather than being
89
+ evicted out from under a call.
90
+
91
+ Call `await client.dispose()` after all work using that adapter has settled.
92
+ Disposal drains queued query calls and closes every cached statement, but does
93
+ **not** close the database handle you supplied. It is terminal: later calls on
94
+ that adapter reject. Close the database separately, after disposal. If you omit
95
+ disposal, the reusable cache is still bounded at 32 entries and lives until the
96
+ underlying handle or process exits; statements evicted while in flight live
97
+ only until their queued calls finish.
98
+
99
+ The adapter serializes concurrent calls that share one cached statement. This
100
+ is required for correctness, not just memory use: the native statement mutates
101
+ its current bindings, so overlapping `all()` calls with different arguments can
102
+ otherwise return another caller's rows. Different cached SQL statements remain
103
+ independent.
104
+
77
105
  ## What is different on Turso
78
106
 
79
107
  ### There is no ANN vector index, so search is a full scan
@@ -192,13 +220,14 @@ point is still type-checked by `deno task check` so the claim above stays true.
192
220
  unchanged, and the main entry point exports no new symbol and references no
193
221
  Turso type.
194
222
 
195
- `tursoAdapter()` returns a `DatabaseAdapter`, and that type is exported from
196
- `libsql-search/turso` so you can name it:
223
+ `tursoAdapter()` returns a `TursoAdapter`, which extends `DatabaseAdapter` with
224
+ the disposal hook. Both types are exported from `libsql-search/turso` so you can
225
+ name them:
197
226
 
198
227
  ```ts
199
- import { tursoAdapter, type DatabaseAdapter } from "libsql-search/turso";
228
+ import { tursoAdapter, type TursoAdapter } from "libsql-search/turso";
200
229
 
201
- let client: DatabaseAdapter;
230
+ let client: TursoAdapter;
202
231
  ```
203
232
 
204
233
  It is exported from the subpath only. The main entry point does not export it,
@@ -217,15 +246,18 @@ to prove the two stay interchangeable, and runs as part of
217
246
  Three things in `src/turso.ts` look like noise and are not. Each has a
218
247
  regression test; none of them fails loudly at runtime if removed.
219
248
 
220
- **Prepared statements must be closed.** A statement holds native memory that the
221
- garbage collector cannot reclaim, because it is not JavaScript heap. Every
222
- `prepare()` is released with `close()` in a `finally`. Measured through the
223
- built bundle, 60 000 `executeQuery()` calls on one handle grow RSS by ~570 MB
224
- without the close and ~150 MB with it. `search()` issues exactly one query, so
225
- an SSR site calling it per request is the case that turns this from untidy into
226
- an OOM. Inside `executeAtomicWrite()` the cached statements are released only
227
- *after* `COMMIT` or `ROLLBACK` a statement stays bound to the transaction
228
- while it is open.
249
+ **Prepared statements are bounded, serialized, and explicitly disposed.** A
250
+ statement holds native memory that the garbage collector cannot reclaim,
251
+ because it is not JavaScript heap. The query path therefore reuses a 32-entry
252
+ LRU instead of preparing on every request, serializes rebinds per entry, and
253
+ closes evictions only after their queued calls finish. `TursoAdapter.dispose()`
254
+ drains and closes the remaining cache. Measured through the built bundle,
255
+ 60 000 `executeQuery()` calls grew RSS by ~570 MB when statements were never
256
+ closed, ~150 MB when each call prepared and closed, and ~7 MB when one statement
257
+ was reused. `search()` issues exactly one query, so the SSR-per-request path is
258
+ where reuse matters most. Inside `executeAtomicWrite()` the transaction-local
259
+ statements are still released only *after* `COMMIT` or `ROLLBACK` — a statement
260
+ stays bound to the transaction while it is open.
229
261
 
230
262
  **`BEGIN IMMEDIATE` sits outside the `try`.** If it were inside, a `BEGIN` that
231
263
  fails because another rebuild already holds the write lock would fall into the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libsql-search",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
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",