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 +90 -7
- package/dist/turso.d.ts +29 -8
- package/dist/turso.esm.js +90 -7
- package/docs/TURSO.md +45 -13
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
117
|
-
*
|
|
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):
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 `
|
|
196
|
-
`libsql-search/turso` so you can
|
|
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
|
|
228
|
+
import { tursoAdapter, type TursoAdapter } from "libsql-search/turso";
|
|
200
229
|
|
|
201
|
-
let client:
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|