verso-db 0.1.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/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +252 -0
- package/dist/BinaryHeap.d.ts +25 -0
- package/dist/BinaryHeap.d.ts.map +1 -0
- package/dist/Collection.d.ts +156 -0
- package/dist/Collection.d.ts.map +1 -0
- package/dist/HNSWIndex.d.ts +357 -0
- package/dist/HNSWIndex.d.ts.map +1 -0
- package/dist/MaxBinaryHeap.d.ts +63 -0
- package/dist/MaxBinaryHeap.d.ts.map +1 -0
- package/dist/Storage.d.ts +54 -0
- package/dist/Storage.d.ts.map +1 -0
- package/dist/VectorDB.d.ts +44 -0
- package/dist/VectorDB.d.ts.map +1 -0
- package/dist/backends/DistanceBackend.d.ts +5 -0
- package/dist/backends/DistanceBackend.d.ts.map +1 -0
- package/dist/backends/JsDistanceBackend.d.ts +37 -0
- package/dist/backends/JsDistanceBackend.d.ts.map +1 -0
- package/dist/encoding/DeltaEncoder.d.ts +61 -0
- package/dist/encoding/DeltaEncoder.d.ts.map +1 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3732 -0
- package/dist/presets.d.ts +91 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/quantization/ScalarQuantizer.d.ts +114 -0
- package/dist/quantization/ScalarQuantizer.d.ts.map +1 -0
- package/dist/storage/BatchWriter.d.ts +104 -0
- package/dist/storage/BatchWriter.d.ts.map +1 -0
- package/dist/storage/BunStorageBackend.d.ts +58 -0
- package/dist/storage/BunStorageBackend.d.ts.map +1 -0
- package/dist/storage/MemoryBackend.d.ts +44 -0
- package/dist/storage/MemoryBackend.d.ts.map +1 -0
- package/dist/storage/OPFSBackend.d.ts +59 -0
- package/dist/storage/OPFSBackend.d.ts.map +1 -0
- package/dist/storage/StorageBackend.d.ts +66 -0
- package/dist/storage/StorageBackend.d.ts.map +1 -0
- package/dist/storage/WriteAheadLog.d.ts +111 -0
- package/dist/storage/WriteAheadLog.d.ts.map +1 -0
- package/dist/storage/createStorageBackend.d.ts +40 -0
- package/dist/storage/createStorageBackend.d.ts.map +1 -0
- package/dist/storage/index.d.ts +30 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/package.json +98 -0
- package/src/BinaryHeap.ts +131 -0
- package/src/Collection.ts +695 -0
- package/src/HNSWIndex.ts +1839 -0
- package/src/MaxBinaryHeap.ts +175 -0
- package/src/Storage.ts +435 -0
- package/src/VectorDB.ts +109 -0
- package/src/backends/DistanceBackend.ts +17 -0
- package/src/backends/JsDistanceBackend.ts +227 -0
- package/src/encoding/DeltaEncoder.ts +217 -0
- package/src/errors.ts +110 -0
- package/src/index.ts +138 -0
- package/src/presets.ts +229 -0
- package/src/quantization/ScalarQuantizer.ts +383 -0
- package/src/storage/BatchWriter.ts +336 -0
- package/src/storage/BunStorageBackend.ts +161 -0
- package/src/storage/MemoryBackend.ts +120 -0
- package/src/storage/OPFSBackend.ts +250 -0
- package/src/storage/StorageBackend.ts +74 -0
- package/src/storage/WriteAheadLog.ts +326 -0
- package/src/storage/createStorageBackend.ts +137 -0
- package/src/storage/index.ts +53 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Origin Private File System (OPFS) Storage Backend
|
|
3
|
+
*
|
|
4
|
+
* High-performance browser storage using the File System Access API.
|
|
5
|
+
* OPFS provides file-like semantics with significantly better performance
|
|
6
|
+
* than IndexedDB for large binary data (up to 10x faster).
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Auto-initializing (no manual init() required)
|
|
10
|
+
* - High performance for large binary data
|
|
11
|
+
*
|
|
12
|
+
* Browser Support:
|
|
13
|
+
* - Chrome 86+
|
|
14
|
+
* - Safari 15.2+
|
|
15
|
+
* - Firefox 111+
|
|
16
|
+
* - Edge 86+
|
|
17
|
+
*
|
|
18
|
+
* Note: This module is designed for browser environments only.
|
|
19
|
+
* It will not work in Node.js/Bun server environments.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { StorageBackend } from './StorageBackend';
|
|
23
|
+
|
|
24
|
+
export class OPFSBackend implements StorageBackend {
|
|
25
|
+
readonly type = 'opfs';
|
|
26
|
+
private root: FileSystemDirectoryHandle | null = null;
|
|
27
|
+
private initialized: boolean = false;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize the OPFS backend (optional - auto-initializes on first operation)
|
|
31
|
+
* @deprecated No longer required - operations auto-initialize
|
|
32
|
+
*/
|
|
33
|
+
async init(): Promise<void> {
|
|
34
|
+
if (this.initialized) return;
|
|
35
|
+
|
|
36
|
+
if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) {
|
|
37
|
+
throw new Error('OPFS not available in this environment. Use MemoryBackend or IndexedDBBackend instead.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.root = await navigator.storage.getDirectory();
|
|
41
|
+
this.initialized = true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ensure backend is initialized (lazy init on first use)
|
|
46
|
+
*/
|
|
47
|
+
private async ensureInitialized(): Promise<void> {
|
|
48
|
+
if (!this.initialized) {
|
|
49
|
+
await this.init();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get a file handle, creating parent directories as needed
|
|
55
|
+
*/
|
|
56
|
+
private async getFileHandle(key: string, create: boolean = false): Promise<FileSystemFileHandle | null> {
|
|
57
|
+
await this.ensureInitialized();
|
|
58
|
+
|
|
59
|
+
const parts = key.split('/');
|
|
60
|
+
const fileName = parts.pop()!;
|
|
61
|
+
|
|
62
|
+
// Navigate/create directories
|
|
63
|
+
let currentDir = this.root!;
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
if (part === '') continue;
|
|
66
|
+
try {
|
|
67
|
+
currentDir = await currentDir.getDirectoryHandle(part, { create });
|
|
68
|
+
} catch {
|
|
69
|
+
if (!create) return null;
|
|
70
|
+
throw new Error(`Failed to create directory: ${part}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Get file handle
|
|
75
|
+
try {
|
|
76
|
+
return await currentDir.getFileHandle(fileName, { create });
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get a directory handle, creating if needed
|
|
84
|
+
*/
|
|
85
|
+
private async getDirectoryHandle(path: string, create: boolean = false): Promise<FileSystemDirectoryHandle | null> {
|
|
86
|
+
await this.ensureInitialized();
|
|
87
|
+
|
|
88
|
+
const parts = path.split('/').filter(p => p !== '');
|
|
89
|
+
|
|
90
|
+
let currentDir = this.root!;
|
|
91
|
+
for (const part of parts) {
|
|
92
|
+
try {
|
|
93
|
+
currentDir = await currentDir.getDirectoryHandle(part, { create });
|
|
94
|
+
} catch {
|
|
95
|
+
if (!create) return null;
|
|
96
|
+
throw new Error(`Failed to get directory: ${part}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return currentDir;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async read(key: string): Promise<ArrayBuffer | null> {
|
|
104
|
+
await this.ensureInitialized();
|
|
105
|
+
|
|
106
|
+
const handle = await this.getFileHandle(key, false);
|
|
107
|
+
if (!handle) return null;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const file = await handle.getFile();
|
|
111
|
+
return file.arrayBuffer();
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async write(key: string, data: ArrayBuffer | Uint8Array): Promise<void> {
|
|
118
|
+
await this.ensureInitialized();
|
|
119
|
+
|
|
120
|
+
const handle = await this.getFileHandle(key, true);
|
|
121
|
+
if (!handle) {
|
|
122
|
+
throw new Error(`Failed to create file: ${key}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const writable = await handle.createWritable();
|
|
126
|
+
try {
|
|
127
|
+
// Convert to ArrayBuffer for FileSystemWriteChunkType compatibility
|
|
128
|
+
const writeData = data instanceof ArrayBuffer ? data : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
|
|
129
|
+
await writable.write(writeData);
|
|
130
|
+
} finally {
|
|
131
|
+
await writable.close();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async append(key: string, data: ArrayBuffer | Uint8Array): Promise<void> {
|
|
136
|
+
await this.ensureInitialized();
|
|
137
|
+
|
|
138
|
+
const handle = await this.getFileHandle(key, true);
|
|
139
|
+
if (!handle) {
|
|
140
|
+
throw new Error(`Failed to create file: ${key}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const appendData = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
144
|
+
|
|
145
|
+
// Get current file size to seek to end
|
|
146
|
+
let existingSize = 0;
|
|
147
|
+
try {
|
|
148
|
+
const file = await handle.getFile();
|
|
149
|
+
existingSize = file.size;
|
|
150
|
+
} catch {
|
|
151
|
+
existingSize = 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Use keepExistingData: true for O(1) append (no full read required)
|
|
155
|
+
// Then seek to end and write only new data
|
|
156
|
+
const writable = await handle.createWritable({ keepExistingData: true });
|
|
157
|
+
try {
|
|
158
|
+
// Seek to end of file
|
|
159
|
+
await writable.seek(existingSize);
|
|
160
|
+
// Write only the new data - O(data.length) not O(existingSize + data.length)
|
|
161
|
+
// Convert to ArrayBuffer for FileSystemWriteChunkType compatibility
|
|
162
|
+
const writeData = appendData.buffer.slice(appendData.byteOffset, appendData.byteOffset + appendData.byteLength) as ArrayBuffer;
|
|
163
|
+
await writable.write(writeData);
|
|
164
|
+
} finally {
|
|
165
|
+
await writable.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async delete(key: string): Promise<void> {
|
|
170
|
+
await this.ensureInitialized();
|
|
171
|
+
|
|
172
|
+
const parts = key.split('/');
|
|
173
|
+
const fileName = parts.pop()!;
|
|
174
|
+
|
|
175
|
+
// Navigate to parent directory
|
|
176
|
+
let currentDir = this.root!;
|
|
177
|
+
for (const part of parts) {
|
|
178
|
+
if (part === '') continue;
|
|
179
|
+
try {
|
|
180
|
+
currentDir = await currentDir.getDirectoryHandle(part, { create: false });
|
|
181
|
+
} catch {
|
|
182
|
+
return; // Parent doesn't exist, nothing to delete
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Remove the file
|
|
187
|
+
try {
|
|
188
|
+
await currentDir.removeEntry(fileName);
|
|
189
|
+
} catch {
|
|
190
|
+
// File may not exist
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async exists(key: string): Promise<boolean> {
|
|
195
|
+
await this.ensureInitialized();
|
|
196
|
+
|
|
197
|
+
const handle = await this.getFileHandle(key, false);
|
|
198
|
+
return handle !== null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async list(prefix?: string): Promise<string[]> {
|
|
202
|
+
await this.ensureInitialized();
|
|
203
|
+
|
|
204
|
+
const results: string[] = [];
|
|
205
|
+
const basePath = prefix || '';
|
|
206
|
+
|
|
207
|
+
const dir = basePath ? await this.getDirectoryHandle(basePath, false) : this.root!;
|
|
208
|
+
if (!dir) return results;
|
|
209
|
+
|
|
210
|
+
// Recursive list
|
|
211
|
+
const listDir = async (dirHandle: FileSystemDirectoryHandle, pathPrefix: string): Promise<void> => {
|
|
212
|
+
for await (const entry of (dirHandle as any).values()) {
|
|
213
|
+
const entryPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
|
|
214
|
+
|
|
215
|
+
if (entry.kind === 'file') {
|
|
216
|
+
results.push(entryPath);
|
|
217
|
+
} else if (entry.kind === 'directory') {
|
|
218
|
+
await listDir(entry, entryPath);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
await listDir(dir, basePath);
|
|
224
|
+
return results;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async mkdir(path: string): Promise<void> {
|
|
228
|
+
await this.ensureInitialized();
|
|
229
|
+
await this.getDirectoryHandle(path, true);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Clear all data in OPFS
|
|
234
|
+
*/
|
|
235
|
+
async clear(): Promise<void> {
|
|
236
|
+
await this.ensureInitialized();
|
|
237
|
+
|
|
238
|
+
// Delete all entries in root
|
|
239
|
+
for await (const entry of (this.root as any).values()) {
|
|
240
|
+
await this.root!.removeEntry(entry.name, { recursive: true });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check if OPFS is available in the current environment
|
|
246
|
+
*/
|
|
247
|
+
static isAvailable(): boolean {
|
|
248
|
+
return typeof navigator !== 'undefined' && !!navigator.storage?.getDirectory;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Backend Interface
|
|
3
|
+
*
|
|
4
|
+
* Provides a unified interface for persisting vector index data across different platforms:
|
|
5
|
+
* - Bun/Node.js: File system storage with Bun.file APIs
|
|
6
|
+
* - Browser: OPFS (Origin Private File System) or IndexedDB
|
|
7
|
+
* - Testing: In-memory storage
|
|
8
|
+
*
|
|
9
|
+
* All operations are async to support both sync and async underlying storage.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface StorageBackend {
|
|
13
|
+
/**
|
|
14
|
+
* Read data from storage
|
|
15
|
+
* @param key Unique key/path for the data
|
|
16
|
+
* @returns ArrayBuffer of data or null if not found
|
|
17
|
+
*/
|
|
18
|
+
read(key: string): Promise<ArrayBuffer | null>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Write data to storage (overwrites existing)
|
|
22
|
+
* @param key Unique key/path for the data
|
|
23
|
+
* @param data Data to write
|
|
24
|
+
*/
|
|
25
|
+
write(key: string, data: ArrayBuffer | Uint8Array): Promise<void>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Append data to existing file (for WAL)
|
|
29
|
+
* @param key Unique key/path for the data
|
|
30
|
+
* @param data Data to append
|
|
31
|
+
*/
|
|
32
|
+
append(key: string, data: ArrayBuffer | Uint8Array): Promise<void>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Delete data from storage
|
|
36
|
+
* @param key Unique key/path to delete
|
|
37
|
+
*/
|
|
38
|
+
delete(key: string): Promise<void>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if key exists in storage
|
|
42
|
+
* @param key Unique key/path to check
|
|
43
|
+
*/
|
|
44
|
+
exists(key: string): Promise<boolean>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* List all keys with given prefix
|
|
48
|
+
* @param prefix Optional prefix to filter keys
|
|
49
|
+
*/
|
|
50
|
+
list(prefix?: string): Promise<string[]>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create a directory (for file-based backends)
|
|
54
|
+
* @param path Directory path to create
|
|
55
|
+
*/
|
|
56
|
+
mkdir(path: string): Promise<void>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get storage type identifier
|
|
60
|
+
*/
|
|
61
|
+
readonly type: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Options for creating a storage backend
|
|
66
|
+
*/
|
|
67
|
+
export interface StorageOptions {
|
|
68
|
+
/** Base path for file-based storage */
|
|
69
|
+
path?: string;
|
|
70
|
+
/** Database name for IndexedDB */
|
|
71
|
+
dbName?: string;
|
|
72
|
+
/** Store name for IndexedDB */
|
|
73
|
+
storeName?: string;
|
|
74
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-Ahead Log (WAL) for Incremental Index Updates
|
|
3
|
+
*
|
|
4
|
+
* Provides durability and efficient incremental writes for HNSW index operations.
|
|
5
|
+
* Instead of rewriting the entire index on each update, operations are appended
|
|
6
|
+
* to a log file. The log can be compacted into a full snapshot periodically.
|
|
7
|
+
*
|
|
8
|
+
* Benefits:
|
|
9
|
+
* - Fast appends (no full serialization)
|
|
10
|
+
* - Crash recovery (replay log after restart)
|
|
11
|
+
* - Reduced I/O for frequent updates
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { appendFile, mkdir, unlink } from 'fs/promises';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
|
|
17
|
+
export enum WALOperationType {
|
|
18
|
+
ADD_VECTOR = 1,
|
|
19
|
+
ADD_NEIGHBORS = 2,
|
|
20
|
+
UPDATE_ENTRY_POINT = 3,
|
|
21
|
+
CHECKPOINT = 4,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WALEntry {
|
|
25
|
+
type: WALOperationType;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
data: ArrayBuffer;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* WriteAheadLog - Append-only log for incremental index updates
|
|
32
|
+
*/
|
|
33
|
+
export class WriteAheadLog {
|
|
34
|
+
private logPath: string;
|
|
35
|
+
private pendingEntries: WALEntry[] = [];
|
|
36
|
+
private flushThreshold: number;
|
|
37
|
+
private lastFlushTime: number = 0;
|
|
38
|
+
private entryCount: number = 0;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a new WAL
|
|
42
|
+
* @param basePath Base path for the index (WAL will be basePath.wal)
|
|
43
|
+
* @param flushThreshold Number of entries before auto-flush (default: 100)
|
|
44
|
+
*/
|
|
45
|
+
constructor(basePath: string, flushThreshold: number = 100) {
|
|
46
|
+
this.logPath = `${basePath}.wal`;
|
|
47
|
+
this.flushThreshold = flushThreshold;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get the WAL file path
|
|
52
|
+
*/
|
|
53
|
+
getPath(): string {
|
|
54
|
+
return this.logPath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if WAL file exists
|
|
59
|
+
*/
|
|
60
|
+
async exists(): Promise<boolean> {
|
|
61
|
+
const file = Bun.file(this.logPath);
|
|
62
|
+
return file.exists();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Append a vector addition operation to the log
|
|
67
|
+
*/
|
|
68
|
+
async appendVector(id: number, vector: Float32Array): Promise<void> {
|
|
69
|
+
// Format: [id (4 bytes)] [vector length (4 bytes)] [vector data]
|
|
70
|
+
const dataSize = 4 + 4 + vector.length * 4;
|
|
71
|
+
const buffer = new ArrayBuffer(dataSize);
|
|
72
|
+
const view = new DataView(buffer);
|
|
73
|
+
|
|
74
|
+
view.setUint32(0, id, true);
|
|
75
|
+
view.setUint32(4, vector.length, true);
|
|
76
|
+
|
|
77
|
+
const floatView = new Float32Array(buffer, 8);
|
|
78
|
+
floatView.set(vector);
|
|
79
|
+
|
|
80
|
+
const entry: WALEntry = {
|
|
81
|
+
type: WALOperationType.ADD_VECTOR,
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
data: buffer,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
this.pendingEntries.push(entry);
|
|
87
|
+
this.entryCount++;
|
|
88
|
+
|
|
89
|
+
if (this.pendingEntries.length >= this.flushThreshold) {
|
|
90
|
+
await this.flush();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Append a neighbor update operation to the log
|
|
96
|
+
*/
|
|
97
|
+
async appendNeighbors(nodeId: number, layer: number, neighbors: number[]): Promise<void> {
|
|
98
|
+
// Format: [nodeId (4)] [layer (4)] [neighborCount (4)] [neighbors...]
|
|
99
|
+
const dataSize = 4 + 4 + 4 + neighbors.length * 4;
|
|
100
|
+
const buffer = new ArrayBuffer(dataSize);
|
|
101
|
+
const view = new DataView(buffer);
|
|
102
|
+
|
|
103
|
+
view.setUint32(0, nodeId, true);
|
|
104
|
+
view.setUint32(4, layer, true);
|
|
105
|
+
view.setUint32(8, neighbors.length, true);
|
|
106
|
+
|
|
107
|
+
let offset = 12;
|
|
108
|
+
for (const neighbor of neighbors) {
|
|
109
|
+
view.setUint32(offset, neighbor, true);
|
|
110
|
+
offset += 4;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const entry: WALEntry = {
|
|
114
|
+
type: WALOperationType.ADD_NEIGHBORS,
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
data: buffer,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
this.pendingEntries.push(entry);
|
|
120
|
+
this.entryCount++;
|
|
121
|
+
|
|
122
|
+
if (this.pendingEntries.length >= this.flushThreshold) {
|
|
123
|
+
await this.flush();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Append entry point update to the log
|
|
129
|
+
*/
|
|
130
|
+
async appendEntryPointUpdate(entryPointId: number, maxLevel: number): Promise<void> {
|
|
131
|
+
const buffer = new ArrayBuffer(8);
|
|
132
|
+
const view = new DataView(buffer);
|
|
133
|
+
|
|
134
|
+
view.setInt32(0, entryPointId, true);
|
|
135
|
+
view.setInt32(4, maxLevel, true);
|
|
136
|
+
|
|
137
|
+
const entry: WALEntry = {
|
|
138
|
+
type: WALOperationType.UPDATE_ENTRY_POINT,
|
|
139
|
+
timestamp: Date.now(),
|
|
140
|
+
data: buffer,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
this.pendingEntries.push(entry);
|
|
144
|
+
this.entryCount++;
|
|
145
|
+
|
|
146
|
+
if (this.pendingEntries.length >= this.flushThreshold) {
|
|
147
|
+
await this.flush();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Write a checkpoint marker to the log
|
|
153
|
+
*/
|
|
154
|
+
async checkpoint(): Promise<void> {
|
|
155
|
+
const buffer = new ArrayBuffer(8);
|
|
156
|
+
const view = new DataView(buffer);
|
|
157
|
+
view.setFloat64(0, Date.now(), true);
|
|
158
|
+
|
|
159
|
+
const entry: WALEntry = {
|
|
160
|
+
type: WALOperationType.CHECKPOINT,
|
|
161
|
+
timestamp: Date.now(),
|
|
162
|
+
data: buffer,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
this.pendingEntries.push(entry);
|
|
166
|
+
await this.flush();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Serialize a WAL entry to bytes
|
|
171
|
+
*/
|
|
172
|
+
private serializeEntry(entry: WALEntry): Uint8Array {
|
|
173
|
+
// Format: [type (1)] [timestamp (8)] [dataLength (4)] [data...]
|
|
174
|
+
const headerSize = 1 + 8 + 4;
|
|
175
|
+
const totalSize = headerSize + entry.data.byteLength;
|
|
176
|
+
const buffer = new ArrayBuffer(totalSize);
|
|
177
|
+
const view = new DataView(buffer);
|
|
178
|
+
|
|
179
|
+
view.setUint8(0, entry.type);
|
|
180
|
+
view.setFloat64(1, entry.timestamp, true);
|
|
181
|
+
view.setUint32(9, entry.data.byteLength, true);
|
|
182
|
+
|
|
183
|
+
const dataView = new Uint8Array(buffer, headerSize);
|
|
184
|
+
dataView.set(new Uint8Array(entry.data));
|
|
185
|
+
|
|
186
|
+
return new Uint8Array(buffer);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Flush pending entries to disk
|
|
191
|
+
* Uses O(1) append instead of O(n) read-modify-write
|
|
192
|
+
*/
|
|
193
|
+
async flush(): Promise<void> {
|
|
194
|
+
if (this.pendingEntries.length === 0) return;
|
|
195
|
+
|
|
196
|
+
// Serialize all pending entries
|
|
197
|
+
const serializedEntries = this.pendingEntries.map(e => this.serializeEntry(e));
|
|
198
|
+
|
|
199
|
+
// Calculate total size
|
|
200
|
+
let totalSize = 0;
|
|
201
|
+
for (const entry of serializedEntries) {
|
|
202
|
+
totalSize += entry.length;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Combine into single buffer
|
|
206
|
+
const combined = new Uint8Array(totalSize);
|
|
207
|
+
let offset = 0;
|
|
208
|
+
for (const entry of serializedEntries) {
|
|
209
|
+
combined.set(entry, offset);
|
|
210
|
+
offset += entry.length;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Use true append - O(1) instead of O(n) read-modify-write
|
|
214
|
+
// Ensure directory exists
|
|
215
|
+
const dir = path.dirname(this.logPath);
|
|
216
|
+
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
217
|
+
|
|
218
|
+
// Append to file (creates if doesn't exist)
|
|
219
|
+
await appendFile(this.logPath, combined);
|
|
220
|
+
|
|
221
|
+
this.pendingEntries = [];
|
|
222
|
+
this.lastFlushTime = Date.now();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Read all entries from the WAL file
|
|
227
|
+
*/
|
|
228
|
+
async readEntries(): Promise<WALEntry[]> {
|
|
229
|
+
const file = Bun.file(this.logPath);
|
|
230
|
+
if (!(await file.exists())) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const buffer = await file.arrayBuffer();
|
|
235
|
+
const view = new DataView(buffer);
|
|
236
|
+
const entries: WALEntry[] = [];
|
|
237
|
+
|
|
238
|
+
let offset = 0;
|
|
239
|
+
while (offset < buffer.byteLength) {
|
|
240
|
+
// Read header
|
|
241
|
+
const type = view.getUint8(offset) as WALOperationType;
|
|
242
|
+
const timestamp = view.getFloat64(offset + 1, true);
|
|
243
|
+
const dataLength = view.getUint32(offset + 9, true);
|
|
244
|
+
|
|
245
|
+
// Read data
|
|
246
|
+
const data = buffer.slice(offset + 13, offset + 13 + dataLength);
|
|
247
|
+
|
|
248
|
+
entries.push({ type, timestamp, data });
|
|
249
|
+
|
|
250
|
+
offset += 13 + dataLength;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return entries;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Parse a vector addition entry
|
|
258
|
+
*/
|
|
259
|
+
static parseVectorEntry(data: ArrayBuffer): { id: number; vector: Float32Array } {
|
|
260
|
+
const view = new DataView(data);
|
|
261
|
+
const id = view.getUint32(0, true);
|
|
262
|
+
const vectorLength = view.getUint32(4, true);
|
|
263
|
+
const vector = new Float32Array(data, 8, vectorLength);
|
|
264
|
+
return { id, vector };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Parse a neighbor update entry
|
|
269
|
+
*/
|
|
270
|
+
static parseNeighborsEntry(data: ArrayBuffer): { nodeId: number; layer: number; neighbors: number[] } {
|
|
271
|
+
const view = new DataView(data);
|
|
272
|
+
const nodeId = view.getUint32(0, true);
|
|
273
|
+
const layer = view.getUint32(4, true);
|
|
274
|
+
const neighborCount = view.getUint32(8, true);
|
|
275
|
+
|
|
276
|
+
const neighbors: number[] = [];
|
|
277
|
+
for (let i = 0; i < neighborCount; i++) {
|
|
278
|
+
neighbors.push(view.getUint32(12 + i * 4, true));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { nodeId, layer, neighbors };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Parse an entry point update entry
|
|
286
|
+
*/
|
|
287
|
+
static parseEntryPointEntry(data: ArrayBuffer): { entryPointId: number; maxLevel: number } {
|
|
288
|
+
const view = new DataView(data);
|
|
289
|
+
return {
|
|
290
|
+
entryPointId: view.getInt32(0, true),
|
|
291
|
+
maxLevel: view.getInt32(4, true),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Get entry count since last compact
|
|
297
|
+
*/
|
|
298
|
+
getEntryCount(): number {
|
|
299
|
+
return this.entryCount;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Clear the WAL (after successful compaction)
|
|
304
|
+
*/
|
|
305
|
+
async clear(): Promise<void> {
|
|
306
|
+
const file = Bun.file(this.logPath);
|
|
307
|
+
if (await file.exists()) {
|
|
308
|
+
await Bun.write(this.logPath, new Uint8Array(0));
|
|
309
|
+
}
|
|
310
|
+
this.pendingEntries = [];
|
|
311
|
+
this.entryCount = 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Delete the WAL file
|
|
316
|
+
*/
|
|
317
|
+
async delete(): Promise<void> {
|
|
318
|
+
try {
|
|
319
|
+
await unlink(this.logPath);
|
|
320
|
+
} catch {
|
|
321
|
+
// File may not exist
|
|
322
|
+
}
|
|
323
|
+
this.pendingEntries = [];
|
|
324
|
+
this.entryCount = 0;
|
|
325
|
+
}
|
|
326
|
+
}
|