lucivy-wasm 0.2.0 → 0.3.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/README.md +71 -60
- package/js/lucivy-worker.js +363 -0
- package/js/lucivy.d.ts +92 -0
- package/js/lucivy.js +133 -0
- package/package.json +22 -14
- package/pkg/lucivy.js +2 -0
- package/pkg/lucivy.wasm +0 -0
- package/LICENSE +0 -22
- package/lucivy_wasm.d.ts +0 -121
- package/lucivy_wasm.js +0 -685
- package/lucivy_wasm_bg.wasm +0 -0
- package/lucivy_wasm_bg.wasm.d.ts +0 -29
- package/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.js +0 -107
package/README.md
CHANGED
|
@@ -1,116 +1,127 @@
|
|
|
1
|
-
#
|
|
1
|
+
# lucivy-wasm
|
|
2
2
|
|
|
3
|
-
Fast BM25 full-text search for browsers — WASM build of Lucivy with OPFS persistence. Runs in a Web Worker.
|
|
3
|
+
Fast BM25 full-text search for browsers — WASM build of Lucivy with **threading** (emscripten pthreads), OPFS persistence, and LUCE snapshot support. Runs in a Web Worker.
|
|
4
|
+
|
|
5
|
+
> **v0.3.0 breaking change**: This package now uses the emscripten build with threading support. The API is now worker-based (async). See below for migration.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
|
-
npm install
|
|
10
|
+
npm install lucivy-wasm
|
|
9
11
|
```
|
|
10
12
|
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- **COOP/COEP headers** required for `SharedArrayBuffer` (threading):
|
|
16
|
+
- `Cross-Origin-Opener-Policy: same-origin`
|
|
17
|
+
- `Cross-Origin-Embedder-Policy: require-corp`
|
|
18
|
+
- For GitHub Pages or environments without header control, use [coi-serviceworker](https://github.com/nicojuicy/coi-serviceworker).
|
|
19
|
+
|
|
11
20
|
## Quick start
|
|
12
21
|
|
|
13
22
|
```javascript
|
|
14
|
-
import
|
|
23
|
+
import { Lucivy } from 'lucivy-wasm';
|
|
15
24
|
|
|
16
|
-
|
|
25
|
+
const lucivy = new Lucivy(new URL('lucivy-wasm/worker', import.meta.url));
|
|
26
|
+
await lucivy.ready;
|
|
17
27
|
|
|
18
|
-
const index =
|
|
28
|
+
const index = await lucivy.create('/my-index', [
|
|
19
29
|
{ name: 'title', type: 'text' },
|
|
20
30
|
{ name: 'body', type: 'text' },
|
|
21
|
-
])
|
|
31
|
+
], 'english');
|
|
22
32
|
|
|
23
|
-
index.add(1,
|
|
24
|
-
index.add(2,
|
|
25
|
-
index.commit();
|
|
33
|
+
await index.add(1, { title: 'Rust Programming', body: 'Systems programming with memory safety' });
|
|
34
|
+
await index.add(2, { title: 'Python Guide', body: 'Data science and web development' });
|
|
35
|
+
await index.commit();
|
|
26
36
|
|
|
27
|
-
const results =
|
|
37
|
+
const results = await index.search('programming');
|
|
28
38
|
for (const r of results) {
|
|
29
|
-
console.log(r.
|
|
39
|
+
console.log(r.docId, r.score);
|
|
30
40
|
}
|
|
41
|
+
|
|
42
|
+
// Cleanup
|
|
43
|
+
lucivy.terminate();
|
|
31
44
|
```
|
|
32
45
|
|
|
33
46
|
## API
|
|
34
47
|
|
|
35
|
-
###
|
|
48
|
+
### Lucivy (main class)
|
|
36
49
|
|
|
37
50
|
```javascript
|
|
51
|
+
import { Lucivy } from 'lucivy-wasm';
|
|
52
|
+
|
|
53
|
+
const lucivy = new Lucivy('./path/to/lucivy-worker.js');
|
|
54
|
+
await lucivy.ready;
|
|
55
|
+
|
|
38
56
|
// Create a new index
|
|
39
|
-
const index =
|
|
57
|
+
const index = await lucivy.create('/my-index', [
|
|
40
58
|
{ name: 'title', type: 'text' },
|
|
41
|
-
{ name: 'body',
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
{ name: 'body', type: 'text' },
|
|
60
|
+
], 'english');
|
|
61
|
+
|
|
62
|
+
// Open an existing index from OPFS
|
|
63
|
+
const index2 = await lucivy.open('/my-index');
|
|
44
64
|
|
|
45
|
-
//
|
|
46
|
-
const
|
|
65
|
+
// Import from a LUCE snapshot
|
|
66
|
+
const index3 = await lucivy.importSnapshot(snapshotBlob, '/restored');
|
|
67
|
+
|
|
68
|
+
// Terminate the worker (frees all WASM memory)
|
|
69
|
+
lucivy.terminate();
|
|
47
70
|
```
|
|
48
71
|
|
|
49
|
-
###
|
|
72
|
+
### LucivyIndex
|
|
50
73
|
|
|
51
74
|
```javascript
|
|
52
|
-
|
|
53
|
-
index.
|
|
75
|
+
// Add / update / delete
|
|
76
|
+
await index.add(1, { title: 'Hello', body: 'World' });
|
|
77
|
+
await index.addMany([
|
|
54
78
|
{ docId: 2, title: 'Foo', body: 'Bar' },
|
|
55
79
|
{ docId: 3, title: 'Baz', body: 'Qux' },
|
|
56
|
-
])
|
|
57
|
-
index.update(1,
|
|
58
|
-
index.remove(2);
|
|
59
|
-
index.commit();
|
|
60
|
-
```
|
|
80
|
+
]);
|
|
81
|
+
await index.update(1, { title: 'Updated', body: 'Content' });
|
|
82
|
+
await index.remove(2);
|
|
83
|
+
await index.commit();
|
|
61
84
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
```javascript
|
|
65
|
-
// String query (contains_split across all text fields)
|
|
66
|
-
const results = JSON.parse(index.search(JSON.stringify('rust programming'), 10));
|
|
85
|
+
// Search (BM25)
|
|
86
|
+
const results = await index.search('rust programming');
|
|
67
87
|
|
|
68
|
-
// Structured query
|
|
69
|
-
const results2 =
|
|
70
|
-
type: 'contains', field: 'body', value: 'program'
|
|
71
|
-
|
|
88
|
+
// Structured query with highlights
|
|
89
|
+
const results2 = await index.search(
|
|
90
|
+
{ type: 'contains', field: 'body', value: 'program' },
|
|
91
|
+
{ highlights: true }
|
|
92
|
+
);
|
|
72
93
|
|
|
73
94
|
// Pre-filtered by doc IDs
|
|
74
|
-
const results3 =
|
|
75
|
-
JSON.stringify('programming'), 10, new Uint32Array([1, 3]), true
|
|
76
|
-
));
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
### OPFS persistence
|
|
80
|
-
|
|
81
|
-
```javascript
|
|
82
|
-
// Get dirty files (modified since last export) for incremental OPFS sync
|
|
83
|
-
const dirty = index.exportDirtyFiles(); // Map<string, Uint8Array>
|
|
95
|
+
const results3 = await index.searchFiltered('programming', [1, 3], { highlights: true });
|
|
84
96
|
|
|
85
|
-
//
|
|
86
|
-
const
|
|
97
|
+
// Metadata
|
|
98
|
+
const count = await index.numDocs();
|
|
99
|
+
const schema = await index.schema();
|
|
87
100
|
```
|
|
88
101
|
|
|
89
|
-
### Snapshots
|
|
102
|
+
### Snapshots
|
|
90
103
|
|
|
91
104
|
```javascript
|
|
92
105
|
// Export to Uint8Array (.luce format)
|
|
93
|
-
const snapshot = index.exportSnapshot();
|
|
106
|
+
const snapshot = await index.exportSnapshot();
|
|
94
107
|
|
|
95
108
|
// Import from Uint8Array
|
|
96
|
-
const restored =
|
|
109
|
+
const restored = await lucivy.importSnapshot(snapshot, '/restored');
|
|
97
110
|
```
|
|
98
111
|
|
|
99
|
-
###
|
|
112
|
+
### Cleanup
|
|
100
113
|
|
|
101
114
|
```javascript
|
|
102
|
-
index.
|
|
103
|
-
index.
|
|
104
|
-
|
|
115
|
+
await index.close(); // Remove from tracking (OPFS files kept)
|
|
116
|
+
await index.destroy(); // Remove from tracking + delete OPFS files
|
|
117
|
+
lucivy.terminate(); // Kill worker, free all WASM memory
|
|
105
118
|
```
|
|
106
119
|
|
|
107
|
-
|
|
120
|
+
> **Note**: Always call `lucivy.terminate()` when done. Individual `close()`/`destroy()` are instant and non-blocking. Actual WASM memory is reclaimed on `terminate()`.
|
|
108
121
|
|
|
109
|
-
|
|
122
|
+
## Supported stemmers
|
|
110
123
|
|
|
111
|
-
|
|
112
|
-
2. After mutations + commit: `index.exportDirtyFiles()` → write to OPFS
|
|
113
|
-
3. For backup/transfer: `index.exportSnapshot()` → portable `.luce` blob
|
|
124
|
+
`english`, `french`, `german`, `spanish`, `italian`, `portuguese`, `dutch`, `russian`
|
|
114
125
|
|
|
115
126
|
## License
|
|
116
127
|
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// lucivy-worker.js — Web Worker that runs lucivy-emscripten with OPFS persistence.
|
|
2
|
+
//
|
|
3
|
+
// Usage from main thread:
|
|
4
|
+
// const worker = new Worker('lucivy-worker.js', { type: 'module' });
|
|
5
|
+
// worker.postMessage({ type: 'init', id: 1 });
|
|
6
|
+
//
|
|
7
|
+
// Or use lucivy.js for a Promise-based API.
|
|
8
|
+
|
|
9
|
+
// The emscripten module must be in ../pkg/lucivy.js relative to this worker.
|
|
10
|
+
// We use importScripts-compatible dynamic import.
|
|
11
|
+
let Module = null;
|
|
12
|
+
|
|
13
|
+
const indexes = new Map(); // path -> ctx pointer
|
|
14
|
+
|
|
15
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function callStr(fn, ...args) {
|
|
18
|
+
const types = args.map(a => typeof a === 'number' ? 'number' : 'string');
|
|
19
|
+
const ptr = Module.ccall(fn, 'number', types, args);
|
|
20
|
+
return Module.UTF8ToString(ptr);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function checkResult(res) {
|
|
24
|
+
if (res.startsWith('{')) {
|
|
25
|
+
const parsed = JSON.parse(res);
|
|
26
|
+
if (parsed.error) throw new Error(parsed.error);
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
if (res !== 'ok') throw new Error(res);
|
|
30
|
+
return res;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getCtx(path) {
|
|
34
|
+
const ctx = indexes.get(path);
|
|
35
|
+
if (!ctx) throw new Error(`No index open at path: ${path}`);
|
|
36
|
+
return ctx;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Base64 decode (for file export from Rust) ────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function base64ToUint8Array(b64) {
|
|
42
|
+
const bin = atob(b64);
|
|
43
|
+
const arr = new Uint8Array(bin.length);
|
|
44
|
+
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
|
45
|
+
return arr;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── OPFS helpers ─────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
async function getOpfsDir(path) {
|
|
51
|
+
const root = await navigator.storage.getDirectory();
|
|
52
|
+
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
53
|
+
let dir = root;
|
|
54
|
+
for (const part of parts) {
|
|
55
|
+
dir = await dir.getDirectoryHandle(part, { create: true });
|
|
56
|
+
}
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readAllFiles(path) {
|
|
61
|
+
const files = new Map();
|
|
62
|
+
try {
|
|
63
|
+
const dir = await getOpfsDir(path);
|
|
64
|
+
for await (const [name, handle] of dir) {
|
|
65
|
+
if (handle.kind === 'file') {
|
|
66
|
+
const file = await handle.getFile();
|
|
67
|
+
const buffer = await file.arrayBuffer();
|
|
68
|
+
files.set(name, new Uint8Array(buffer));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// Directory doesn't exist yet — return empty map.
|
|
73
|
+
}
|
|
74
|
+
return files;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function writeFiles(path, modified, deleted) {
|
|
78
|
+
const dir = await getOpfsDir(path);
|
|
79
|
+
|
|
80
|
+
for (const [name, data] of modified) {
|
|
81
|
+
const fileHandle = await dir.getFileHandle(name, { create: true });
|
|
82
|
+
const writable = await fileHandle.createWritable();
|
|
83
|
+
await writable.write(data);
|
|
84
|
+
await writable.close();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const name of deleted) {
|
|
88
|
+
try {
|
|
89
|
+
await dir.removeEntry(name);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// File may already be gone.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function removeAllFiles(path) {
|
|
97
|
+
try {
|
|
98
|
+
const root = await navigator.storage.getDirectory();
|
|
99
|
+
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
100
|
+
if (parts.length > 0) {
|
|
101
|
+
await root.removeEntry(parts[0], { recursive: true });
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// Directory doesn't exist.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Sync dirty files from emscripten index to OPFS ──────────────────────────
|
|
109
|
+
|
|
110
|
+
async function syncDirtyToOpfs(path, ctx) {
|
|
111
|
+
const dirtyJson = callStr('lucivy_export_dirty', ctx);
|
|
112
|
+
const dirty = JSON.parse(dirtyJson);
|
|
113
|
+
|
|
114
|
+
const modified = (dirty.modified || []).map(([name, b64]) => [name, base64ToUint8Array(b64)]);
|
|
115
|
+
const deleted = dirty.deleted || [];
|
|
116
|
+
|
|
117
|
+
if (modified.length > 0 || deleted.length > 0) {
|
|
118
|
+
await writeFiles(path, modified, deleted);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Message handler ──────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
self.onmessage = async (e) => {
|
|
125
|
+
const { type, id, ...args } = e.data;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
if (!Module && type !== 'init') {
|
|
129
|
+
throw new Error('Module not initialized — send {type: "init"} first');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let result;
|
|
133
|
+
|
|
134
|
+
switch (type) {
|
|
135
|
+
case 'init': {
|
|
136
|
+
// Dynamic import of the emscripten module.
|
|
137
|
+
// The worker URL determines the base for relative imports.
|
|
138
|
+
const { default: createLucivy } = await import('../pkg/lucivy.js');
|
|
139
|
+
Module = await createLucivy();
|
|
140
|
+
result = true;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case 'create': {
|
|
145
|
+
const { path, fields, stemmer } = args;
|
|
146
|
+
const fieldsJson = typeof fields === 'string' ? fields : JSON.stringify(fields);
|
|
147
|
+
const ctx = Module.ccall('lucivy_create', 'number',
|
|
148
|
+
['string', 'string', 'string'],
|
|
149
|
+
[path, fieldsJson, stemmer || '']);
|
|
150
|
+
if (!ctx) throw new Error('lucivy_create returned null');
|
|
151
|
+
indexes.set(path, ctx);
|
|
152
|
+
|
|
153
|
+
// Export all files to OPFS for initial persistence (best-effort).
|
|
154
|
+
try {
|
|
155
|
+
const allJson = callStr('lucivy_export_all', ctx);
|
|
156
|
+
const allFiles = JSON.parse(allJson);
|
|
157
|
+
const modified = allFiles.map(([name, b64]) => [name, base64ToUint8Array(b64)]);
|
|
158
|
+
await writeFiles(path, modified, []);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
console.warn('[lucivy-worker] OPFS initial sync skipped:', e.message);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
result = { path, numDocs: Module.ccall('lucivy_num_docs', 'number', ['number'], [ctx]) };
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case 'open': {
|
|
168
|
+
const { path } = args;
|
|
169
|
+
const files = await readAllFiles(path);
|
|
170
|
+
if (files.size === 0) {
|
|
171
|
+
throw new Error(`No index found at OPFS path: ${path}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Two-phase open: begin → import files → finish.
|
|
175
|
+
const openCtx = Module.ccall('lucivy_open_begin', 'number', ['string'], [path]);
|
|
176
|
+
if (!openCtx) throw new Error('lucivy_open_begin returned null');
|
|
177
|
+
|
|
178
|
+
for (const [name, data] of files) {
|
|
179
|
+
// Allocate memory and copy file data.
|
|
180
|
+
const ptr = Module._malloc(data.length);
|
|
181
|
+
Module.HEAPU8.set(data, ptr);
|
|
182
|
+
// Write file name as UTF8.
|
|
183
|
+
const nameBytes = Module.lengthBytesUTF8(name) + 1;
|
|
184
|
+
const namePtr = Module._malloc(nameBytes);
|
|
185
|
+
Module.stringToUTF8(name, namePtr, nameBytes);
|
|
186
|
+
Module.ccall('lucivy_import_file', null,
|
|
187
|
+
['number', 'number', 'number', 'number'],
|
|
188
|
+
[openCtx, namePtr, ptr, data.length]);
|
|
189
|
+
Module._free(namePtr);
|
|
190
|
+
Module._free(ptr);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const ctx = Module.ccall('lucivy_open_finish', 'number', ['number'], [openCtx]);
|
|
194
|
+
if (!ctx) throw new Error('lucivy_open_finish returned null');
|
|
195
|
+
indexes.set(path, ctx);
|
|
196
|
+
|
|
197
|
+
result = { path, numDocs: Module.ccall('lucivy_num_docs', 'number', ['number'], [ctx]) };
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case 'add': {
|
|
202
|
+
const ctx = getCtx(args.path);
|
|
203
|
+
const fieldsJson = typeof args.fields === 'string'
|
|
204
|
+
? args.fields : JSON.stringify(args.fields);
|
|
205
|
+
const res = callStr('lucivy_add', ctx, args.docId, fieldsJson);
|
|
206
|
+
checkResult(res);
|
|
207
|
+
result = true;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case 'addMany': {
|
|
212
|
+
const ctx = getCtx(args.path);
|
|
213
|
+
const docsJson = typeof args.docs === 'string'
|
|
214
|
+
? args.docs : JSON.stringify(args.docs);
|
|
215
|
+
const res = callStr('lucivy_add_many', ctx, docsJson);
|
|
216
|
+
checkResult(res);
|
|
217
|
+
result = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
case 'remove': {
|
|
222
|
+
const ctx = getCtx(args.path);
|
|
223
|
+
const res = callStr('lucivy_remove', ctx, args.docId);
|
|
224
|
+
checkResult(res);
|
|
225
|
+
result = true;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case 'update': {
|
|
230
|
+
const ctx = getCtx(args.path);
|
|
231
|
+
const fieldsJson = typeof args.fields === 'string'
|
|
232
|
+
? args.fields : JSON.stringify(args.fields);
|
|
233
|
+
const res = callStr('lucivy_update', ctx, args.docId, fieldsJson);
|
|
234
|
+
checkResult(res);
|
|
235
|
+
result = true;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
case 'commit': {
|
|
240
|
+
const ctx = getCtx(args.path);
|
|
241
|
+
const res = callStr('lucivy_commit', ctx);
|
|
242
|
+
checkResult(res);
|
|
243
|
+
|
|
244
|
+
// Sync dirty files to OPFS (best-effort — may not be available in all contexts).
|
|
245
|
+
try {
|
|
246
|
+
await syncDirtyToOpfs(args.path, ctx);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
console.warn('[lucivy-worker] OPFS sync skipped:', e.message);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
result = { numDocs: Module.ccall('lucivy_num_docs', 'number', ['number'], [ctx]) };
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case 'rollback': {
|
|
256
|
+
const ctx = getCtx(args.path);
|
|
257
|
+
const res = callStr('lucivy_rollback', ctx);
|
|
258
|
+
checkResult(res);
|
|
259
|
+
result = true;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case 'search': {
|
|
264
|
+
const ctx = getCtx(args.path);
|
|
265
|
+
const queryJson = typeof args.query === 'string' && !args.query.startsWith('{')
|
|
266
|
+
? JSON.stringify(args.query)
|
|
267
|
+
: (typeof args.query === 'object' ? JSON.stringify(args.query) : args.query);
|
|
268
|
+
const json = callStr('lucivy_search', ctx, queryJson, args.limit || 10, args.highlights ? 1 : 0);
|
|
269
|
+
result = JSON.parse(json);
|
|
270
|
+
if (result.error) throw new Error(result.error);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
case 'searchFiltered': {
|
|
275
|
+
const ctx = getCtx(args.path);
|
|
276
|
+
const queryJson = typeof args.query === 'string' && !args.query.startsWith('{')
|
|
277
|
+
? JSON.stringify(args.query)
|
|
278
|
+
: (typeof args.query === 'object' ? JSON.stringify(args.query) : args.query);
|
|
279
|
+
|
|
280
|
+
const ids = new Uint32Array(args.allowedIds);
|
|
281
|
+
const idsPtr = Module._malloc(ids.byteLength);
|
|
282
|
+
Module.HEAPU8.set(new Uint8Array(ids.buffer), idsPtr);
|
|
283
|
+
|
|
284
|
+
const resPtr = Module.ccall('lucivy_search_filtered', 'number',
|
|
285
|
+
['number', 'string', 'number', 'number', 'number', 'number'],
|
|
286
|
+
[ctx, queryJson, args.limit || 10, idsPtr, ids.length, args.highlights ? 1 : 0]);
|
|
287
|
+
const json = Module.UTF8ToString(resPtr);
|
|
288
|
+
Module._free(idsPtr);
|
|
289
|
+
|
|
290
|
+
result = JSON.parse(json);
|
|
291
|
+
if (result.error) throw new Error(result.error);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case 'close': {
|
|
296
|
+
// Remove from tracking. The Rust context is intentionally NOT destroyed
|
|
297
|
+
// here because lucivy_destroy can deadlock under emscripten pthreads
|
|
298
|
+
// (IndexWriter drop joins threads via the event loop).
|
|
299
|
+
// All WASM memory is fully reclaimed when the worker is terminated.
|
|
300
|
+
indexes.delete(args.path);
|
|
301
|
+
result = true;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
case 'destroy': {
|
|
306
|
+
indexes.delete(args.path);
|
|
307
|
+
// OPFS cleanup is best-effort (may hang if storage API unavailable)
|
|
308
|
+
removeAllFiles(args.path).catch(() => {});
|
|
309
|
+
result = true;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
case 'exportSnapshot': {
|
|
314
|
+
const ctx = getCtx(args.path);
|
|
315
|
+
const lenPtr = Module._malloc(4);
|
|
316
|
+
const dataPtr = Module.ccall('lucivy_export_snapshot', 'number',
|
|
317
|
+
['number', 'number'], [ctx, lenPtr]);
|
|
318
|
+
if (!dataPtr) {
|
|
319
|
+
Module._free(lenPtr);
|
|
320
|
+
throw new Error('export failed — index may have uncommitted changes');
|
|
321
|
+
}
|
|
322
|
+
const len = Module.getValue(lenPtr, 'i32');
|
|
323
|
+
Module._free(lenPtr);
|
|
324
|
+
result = Module.HEAPU8.slice(dataPtr, dataPtr + len);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
case 'importSnapshot': {
|
|
329
|
+
const { data, path } = args;
|
|
330
|
+
const dataArr = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
331
|
+
const ptr = Module._malloc(dataArr.length);
|
|
332
|
+
Module.HEAPU8.set(dataArr, ptr);
|
|
333
|
+
const ctx = Module.ccall('lucivy_import_snapshot', 'number',
|
|
334
|
+
['number', 'number', 'string'], [ptr, dataArr.length, path]);
|
|
335
|
+
Module._free(ptr);
|
|
336
|
+
if (!ctx) throw new Error('import_snapshot failed — invalid snapshot data');
|
|
337
|
+
indexes.set(path, ctx);
|
|
338
|
+
result = { path, numDocs: Module.ccall('lucivy_num_docs', 'number', ['number'], [ctx]) };
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
case 'numDocs': {
|
|
343
|
+
const ctx = getCtx(args.path);
|
|
344
|
+
result = Module.ccall('lucivy_num_docs', 'number', ['number'], [ctx]);
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
case 'schema': {
|
|
349
|
+
const ctx = getCtx(args.path);
|
|
350
|
+
const json = callStr('lucivy_schema_json', ctx);
|
|
351
|
+
result = json ? JSON.parse(json) : null;
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
default:
|
|
356
|
+
throw new Error(`Unknown message type: ${type}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
self.postMessage({ id, result });
|
|
360
|
+
} catch (err) {
|
|
361
|
+
self.postMessage({ id, error: err.message || String(err) });
|
|
362
|
+
}
|
|
363
|
+
};
|
package/js/lucivy.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Field definition for creating an index. */
|
|
2
|
+
export interface FieldDef {
|
|
3
|
+
name: string;
|
|
4
|
+
type: 'text' | 'string' | 'u64' | 'i64' | 'f64';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Search query — either a plain string or a structured query object. */
|
|
8
|
+
export type SearchQuery = string | {
|
|
9
|
+
type: 'contains' | 'contains_split' | 'term' | 'boolean';
|
|
10
|
+
field?: string;
|
|
11
|
+
value?: string;
|
|
12
|
+
should?: SearchQuery[];
|
|
13
|
+
must?: SearchQuery[];
|
|
14
|
+
must_not?: SearchQuery[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Search options. */
|
|
18
|
+
export interface SearchOptions {
|
|
19
|
+
limit?: number;
|
|
20
|
+
highlights?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A single search result. */
|
|
24
|
+
export interface SearchResult {
|
|
25
|
+
docId: number;
|
|
26
|
+
score: number;
|
|
27
|
+
highlights?: Record<string, [number, number][]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Main-thread Promise API for lucivy-emscripten. */
|
|
31
|
+
export declare class Lucivy {
|
|
32
|
+
/** Resolves when the WASM module is loaded and ready. */
|
|
33
|
+
readonly ready: Promise<boolean>;
|
|
34
|
+
|
|
35
|
+
constructor(workerUrl: string);
|
|
36
|
+
|
|
37
|
+
/** Create a new index at the given path with the specified fields. */
|
|
38
|
+
create(path: string, fields: FieldDef[], stemmer?: string): Promise<LucivyIndex>;
|
|
39
|
+
|
|
40
|
+
/** Open an existing index from OPFS. */
|
|
41
|
+
open(path: string): Promise<LucivyIndex>;
|
|
42
|
+
|
|
43
|
+
/** Import an index from a LUCE snapshot blob. */
|
|
44
|
+
importSnapshot(data: Uint8Array, path: string): Promise<LucivyIndex>;
|
|
45
|
+
|
|
46
|
+
/** Terminate the worker. */
|
|
47
|
+
terminate(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Handle to an open lucivy index. All operations go through the worker. */
|
|
51
|
+
export declare class LucivyIndex {
|
|
52
|
+
readonly path: string;
|
|
53
|
+
|
|
54
|
+
/** Add a document with the given ID and field values. */
|
|
55
|
+
add(docId: number, fields: Record<string, string | number>): Promise<boolean>;
|
|
56
|
+
|
|
57
|
+
/** Add multiple documents at once. Each doc must have a `docId` key. */
|
|
58
|
+
addMany(docs: Array<Record<string, string | number> & { docId: number }>): Promise<boolean>;
|
|
59
|
+
|
|
60
|
+
/** Remove a document by ID. */
|
|
61
|
+
remove(docId: number): Promise<boolean>;
|
|
62
|
+
|
|
63
|
+
/** Update a document (remove + add). */
|
|
64
|
+
update(docId: number, fields: Record<string, string | number>): Promise<boolean>;
|
|
65
|
+
|
|
66
|
+
/** Commit pending changes and sync to OPFS. */
|
|
67
|
+
commit(): Promise<{ numDocs: number }>;
|
|
68
|
+
|
|
69
|
+
/** Rollback uncommitted changes. */
|
|
70
|
+
rollback(): Promise<boolean>;
|
|
71
|
+
|
|
72
|
+
/** Search the index. */
|
|
73
|
+
search(query: SearchQuery, options?: SearchOptions): Promise<SearchResult[]>;
|
|
74
|
+
|
|
75
|
+
/** Search with an allowed document ID filter. */
|
|
76
|
+
searchFiltered(query: SearchQuery, allowedIds: number[], options?: SearchOptions): Promise<SearchResult[]>;
|
|
77
|
+
|
|
78
|
+
/** Get the number of indexed documents. */
|
|
79
|
+
numDocs(): Promise<number>;
|
|
80
|
+
|
|
81
|
+
/** Get the index schema. */
|
|
82
|
+
schema(): Promise<FieldDef[] | null>;
|
|
83
|
+
|
|
84
|
+
/** Export the index as a LUCE snapshot blob (Uint8Array). */
|
|
85
|
+
exportSnapshot(): Promise<Uint8Array>;
|
|
86
|
+
|
|
87
|
+
/** Close the index (keep OPFS files). */
|
|
88
|
+
close(): Promise<boolean>;
|
|
89
|
+
|
|
90
|
+
/** Close the index and delete OPFS files. */
|
|
91
|
+
destroy(): Promise<boolean>;
|
|
92
|
+
}
|