@hashtree/dexie 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sirius Business Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,42 @@
1
+ import type { Store, Hash } from '@hashtree/core';
2
+ /**
3
+ * Dexie-based Store implementation
4
+ * Drop-in replacement for IndexedDBStore with better error handling
5
+ */
6
+ export declare class DexieStore implements Store {
7
+ private db;
8
+ constructor(dbName?: string);
9
+ put(hash: Hash, data: Uint8Array): Promise<boolean>;
10
+ get(hash: Hash): Promise<Uint8Array | null>;
11
+ has(hash: Hash): Promise<boolean>;
12
+ delete(hash: Hash): Promise<boolean>;
13
+ /**
14
+ * Get all stored hashes
15
+ */
16
+ keys(): Promise<Hash[]>;
17
+ /**
18
+ * Clear all data
19
+ */
20
+ clear(): Promise<void>;
21
+ /**
22
+ * Get count of stored items
23
+ */
24
+ count(): Promise<number>;
25
+ /**
26
+ * Get total bytes stored
27
+ */
28
+ totalBytes(): Promise<number>;
29
+ /**
30
+ * Evict least-recently-used entries until totalBytes is below maxBytes.
31
+ * Returns the number of entries deleted.
32
+ */
33
+ evict(maxBytes: number): Promise<number>;
34
+ /**
35
+ * Close the database connection
36
+ */
37
+ close(): void;
38
+ /**
39
+ * Delete the entire database
40
+ */
41
+ static deleteDatabase(dbName?: string): Promise<void>;
42
+ }
package/dist/index.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Dexie-based IndexedDB store for hashtree blobs
3
+ * More robust than raw IndexedDB - handles errors, upgrades, and stuck connections better
4
+ */
5
+ import Dexie from 'dexie';
6
+ import { toHex, fromHex } from '@hashtree/core';
7
+ class HashTreeDB extends Dexie {
8
+ blobs;
9
+ constructor(dbName) {
10
+ super(dbName);
11
+ // Version 1: Original schema without lastAccess
12
+ this.version(1).stores({
13
+ blobs: '&hashHex',
14
+ });
15
+ // Version 2: Add lastAccess field for LRU eviction
16
+ this.version(2).stores({
17
+ blobs: '&hashHex, lastAccess',
18
+ }).upgrade(tx => {
19
+ // Add lastAccess to existing entries
20
+ const now = Date.now();
21
+ return tx.table('blobs').toCollection().modify(blob => {
22
+ blob.lastAccess = now;
23
+ });
24
+ });
25
+ }
26
+ }
27
+ /**
28
+ * Dexie-based Store implementation
29
+ * Drop-in replacement for IndexedDBStore with better error handling
30
+ */
31
+ export class DexieStore {
32
+ db;
33
+ constructor(dbName = 'hashtree') {
34
+ this.db = new HashTreeDB(dbName);
35
+ }
36
+ async put(hash, data) {
37
+ const hashHex = toHex(hash);
38
+ try {
39
+ await this.db.blobs.put({ hashHex, data: new Uint8Array(data), lastAccess: Date.now() });
40
+ return true;
41
+ }
42
+ catch (e) {
43
+ console.error('[DexieStore] put error:', e);
44
+ return false;
45
+ }
46
+ }
47
+ async get(hash) {
48
+ if (!hash)
49
+ return null;
50
+ const hashHex = toHex(hash);
51
+ try {
52
+ const entry = await this.db.blobs.get(hashHex);
53
+ if (!entry)
54
+ return null;
55
+ // Update lastAccess timestamp for LRU tracking (fire-and-forget)
56
+ // We need to re-put the entry to avoid fake-indexeddb corruption issues with partial updates
57
+ this.db.blobs.put({ ...entry, lastAccess: Date.now() }).catch(() => { });
58
+ // Use slice to ensure we get exact data without extra buffer bytes
59
+ // IndexedDB can store the entire backing ArrayBuffer of a view
60
+ const data = entry.data;
61
+ return new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
62
+ }
63
+ catch (e) {
64
+ console.error('[DexieStore] get error:', e);
65
+ return null;
66
+ }
67
+ }
68
+ async has(hash) {
69
+ const hashHex = toHex(hash);
70
+ try {
71
+ const entry = await this.db.blobs.get(hashHex);
72
+ return entry !== undefined;
73
+ }
74
+ catch (e) {
75
+ console.error('[DexieStore] has error:', e);
76
+ return false;
77
+ }
78
+ }
79
+ async delete(hash) {
80
+ const hashHex = toHex(hash);
81
+ try {
82
+ const existed = await this.has(hash);
83
+ if (existed) {
84
+ await this.db.blobs.delete(hashHex);
85
+ return true;
86
+ }
87
+ return false;
88
+ }
89
+ catch (e) {
90
+ console.error('[DexieStore] delete error:', e);
91
+ return false;
92
+ }
93
+ }
94
+ /**
95
+ * Get all stored hashes
96
+ */
97
+ async keys() {
98
+ try {
99
+ const hexKeys = await this.db.blobs.toCollection().primaryKeys();
100
+ return hexKeys.map(hex => fromHex(hex));
101
+ }
102
+ catch (e) {
103
+ console.error('[DexieStore] keys error:', e);
104
+ return [];
105
+ }
106
+ }
107
+ /**
108
+ * Clear all data
109
+ */
110
+ async clear() {
111
+ try {
112
+ await this.db.blobs.clear();
113
+ }
114
+ catch (e) {
115
+ console.error('[DexieStore] clear error:', e);
116
+ }
117
+ }
118
+ /**
119
+ * Get count of stored items
120
+ */
121
+ async count() {
122
+ try {
123
+ return await this.db.blobs.count();
124
+ }
125
+ catch (e) {
126
+ console.error('[DexieStore] count error:', e);
127
+ return 0;
128
+ }
129
+ }
130
+ /**
131
+ * Get total bytes stored
132
+ */
133
+ async totalBytes() {
134
+ try {
135
+ let total = 0;
136
+ await this.db.blobs.each(e => {
137
+ total += e.data.length;
138
+ });
139
+ return total;
140
+ }
141
+ catch (e) {
142
+ console.error('[DexieStore] totalBytes error:', e);
143
+ return 0;
144
+ }
145
+ }
146
+ /**
147
+ * Evict least-recently-used entries until totalBytes is below maxBytes.
148
+ * Returns the number of entries deleted.
149
+ */
150
+ async evict(maxBytes) {
151
+ try {
152
+ const currentBytes = await this.totalBytes();
153
+ if (currentBytes <= maxBytes)
154
+ return 0;
155
+ // Get entries sorted by lastAccess (oldest first)
156
+ const entries = await this.db.blobs.orderBy('lastAccess').toArray();
157
+ let bytesRemoved = 0;
158
+ let entriesRemoved = 0;
159
+ const targetRemoval = currentBytes - maxBytes;
160
+ for (const entry of entries) {
161
+ if (bytesRemoved >= targetRemoval)
162
+ break;
163
+ await this.db.blobs.delete(entry.hashHex);
164
+ bytesRemoved += entry.data.length;
165
+ entriesRemoved++;
166
+ }
167
+ console.log(`[DexieStore] Evicted ${entriesRemoved} entries (${bytesRemoved} bytes)`);
168
+ return entriesRemoved;
169
+ }
170
+ catch (e) {
171
+ console.error('[DexieStore] evict error:', e);
172
+ return 0;
173
+ }
174
+ }
175
+ /**
176
+ * Close the database connection
177
+ */
178
+ close() {
179
+ this.db.close();
180
+ }
181
+ /**
182
+ * Delete the entire database
183
+ */
184
+ static async deleteDatabase(dbName = 'hashtree') {
185
+ await Dexie.delete(dbName);
186
+ }
187
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Unit tests for DexieStore
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+ import { DexieStore } from './index.js';
6
+ // Helper to create a hash from data (simplified for tests)
7
+ function makeHash(data) {
8
+ // Create a 32-byte hash-like value (not cryptographic, just for testing)
9
+ const hash = new Uint8Array(32);
10
+ for (let i = 0; i < data.length && i < 32; i++) {
11
+ hash[i] = data[i];
12
+ }
13
+ // Add some uniqueness based on length
14
+ hash[31] = data.length % 256;
15
+ return hash;
16
+ }
17
+ describe('DexieStore', () => {
18
+ let store;
19
+ const TEST_DB_NAME = 'test-dexie-store';
20
+ beforeEach(async () => {
21
+ // Delete any existing test database
22
+ await DexieStore.deleteDatabase(TEST_DB_NAME);
23
+ store = new DexieStore(TEST_DB_NAME);
24
+ });
25
+ afterEach(async () => {
26
+ store.close();
27
+ await DexieStore.deleteDatabase(TEST_DB_NAME);
28
+ });
29
+ describe('put and get', () => {
30
+ it('should store and retrieve data', async () => {
31
+ const data = new TextEncoder().encode('hello world');
32
+ const hash = makeHash(data);
33
+ await store.put(hash, data);
34
+ const retrieved = await store.get(hash);
35
+ expect(retrieved).not.toBeNull();
36
+ expect(new TextDecoder().decode(retrieved)).toBe('hello world');
37
+ });
38
+ it('should return null for non-existent hash', async () => {
39
+ const hash = makeHash(new Uint8Array([1, 2, 3]));
40
+ const retrieved = await store.get(hash);
41
+ expect(retrieved).toBeNull();
42
+ });
43
+ it('should handle empty data', async () => {
44
+ const data = new Uint8Array(0);
45
+ const hash = makeHash(data);
46
+ await store.put(hash, data);
47
+ const retrieved = await store.get(hash);
48
+ expect(retrieved).not.toBeNull();
49
+ expect(retrieved.length).toBe(0);
50
+ });
51
+ it('should handle binary data', async () => {
52
+ const data = new Uint8Array([0, 1, 127, 128, 255]);
53
+ const hash = makeHash(data);
54
+ await store.put(hash, data);
55
+ const retrieved = await store.get(hash);
56
+ expect(retrieved).not.toBeNull();
57
+ expect(Array.from(retrieved)).toEqual([0, 1, 127, 128, 255]);
58
+ });
59
+ });
60
+ describe('has', () => {
61
+ it('should return true for existing data', async () => {
62
+ const data = new TextEncoder().encode('test');
63
+ const hash = makeHash(data);
64
+ await store.put(hash, data);
65
+ expect(await store.has(hash)).toBe(true);
66
+ });
67
+ it('should return false for non-existent data', async () => {
68
+ const hash = makeHash(new Uint8Array([1, 2, 3]));
69
+ expect(await store.has(hash)).toBe(false);
70
+ });
71
+ });
72
+ describe('delete', () => {
73
+ it('should delete existing data', async () => {
74
+ const data = new TextEncoder().encode('to delete');
75
+ const hash = makeHash(data);
76
+ await store.put(hash, data);
77
+ expect(await store.has(hash)).toBe(true);
78
+ const deleted = await store.delete(hash);
79
+ expect(deleted).toBe(true);
80
+ expect(await store.has(hash)).toBe(false);
81
+ });
82
+ it('should return false for non-existent data', async () => {
83
+ const hash = makeHash(new Uint8Array([1, 2, 3]));
84
+ const deleted = await store.delete(hash);
85
+ expect(deleted).toBe(false);
86
+ });
87
+ });
88
+ describe('count and totalBytes', () => {
89
+ it('should count items correctly', async () => {
90
+ expect(await store.count()).toBe(0);
91
+ const data1 = new TextEncoder().encode('one');
92
+ const data2 = new TextEncoder().encode('two');
93
+ const data3 = new TextEncoder().encode('three');
94
+ await store.put(makeHash(data1), data1);
95
+ expect(await store.count()).toBe(1);
96
+ await store.put(makeHash(data2), data2);
97
+ expect(await store.count()).toBe(2);
98
+ await store.put(makeHash(data3), data3);
99
+ expect(await store.count()).toBe(3);
100
+ });
101
+ it('should calculate total bytes correctly', async () => {
102
+ expect(await store.totalBytes()).toBe(0);
103
+ const data1 = new Uint8Array(100);
104
+ const data2 = new Uint8Array(200);
105
+ data2[0] = 1; // Make it different from data1
106
+ await store.put(makeHash(data1), data1);
107
+ expect(await store.totalBytes()).toBe(100);
108
+ await store.put(makeHash(data2), data2);
109
+ expect(await store.totalBytes()).toBe(300);
110
+ });
111
+ });
112
+ describe('keys', () => {
113
+ it('should return all stored hashes', async () => {
114
+ const data1 = new TextEncoder().encode('one');
115
+ const data2 = new TextEncoder().encode('two');
116
+ const hash1 = makeHash(data1);
117
+ const hash2 = makeHash(data2);
118
+ await store.put(hash1, data1);
119
+ await store.put(hash2, data2);
120
+ const keys = await store.keys();
121
+ expect(keys.length).toBe(2);
122
+ });
123
+ it('should return empty array for empty store', async () => {
124
+ const keys = await store.keys();
125
+ expect(keys.length).toBe(0);
126
+ });
127
+ });
128
+ describe('clear', () => {
129
+ it('should remove all data', async () => {
130
+ const data1 = new TextEncoder().encode('one');
131
+ const data2 = new TextEncoder().encode('two');
132
+ await store.put(makeHash(data1), data1);
133
+ await store.put(makeHash(data2), data2);
134
+ expect(await store.count()).toBe(2);
135
+ await store.clear();
136
+ expect(await store.count()).toBe(0);
137
+ });
138
+ });
139
+ describe('persistence', () => {
140
+ it('should persist data across store instances', async () => {
141
+ const data = new TextEncoder().encode('persistent');
142
+ const hash = makeHash(data);
143
+ await store.put(hash, data);
144
+ store.close();
145
+ // Create new instance with same db name
146
+ const store2 = new DexieStore(TEST_DB_NAME);
147
+ const retrieved = await store2.get(hash);
148
+ expect(retrieved).not.toBeNull();
149
+ expect(new TextDecoder().decode(retrieved)).toBe('persistent');
150
+ store2.close();
151
+ });
152
+ });
153
+ describe('evict', () => {
154
+ it('should evict entries when over limit', async () => {
155
+ // Add 3 entries of 100 bytes each (300 bytes total)
156
+ const data1 = new Uint8Array(100);
157
+ const data2 = new Uint8Array(100);
158
+ const data3 = new Uint8Array(100);
159
+ data1[0] = 1;
160
+ data2[0] = 2;
161
+ data3[0] = 3;
162
+ const hash1 = makeHash(data1);
163
+ const hash2 = makeHash(data2);
164
+ const hash3 = makeHash(data3);
165
+ await store.put(hash1, data1);
166
+ // Small delay to ensure different lastAccess times
167
+ await new Promise(r => setTimeout(r, 10));
168
+ await store.put(hash2, data2);
169
+ await new Promise(r => setTimeout(r, 10));
170
+ await store.put(hash3, data3);
171
+ expect(await store.count()).toBe(3);
172
+ expect(await store.totalBytes()).toBe(300);
173
+ // Evict to max 150 bytes - should remove oldest entries
174
+ const evicted = await store.evict(150);
175
+ expect(evicted).toBeGreaterThan(0);
176
+ const remaining = await store.totalBytes();
177
+ expect(remaining).toBeLessThanOrEqual(150);
178
+ });
179
+ it('should not evict when under limit', async () => {
180
+ const data = new Uint8Array(100);
181
+ const hash = makeHash(data);
182
+ await store.put(hash, data);
183
+ const evicted = await store.evict(1000);
184
+ expect(evicted).toBe(0);
185
+ expect(await store.count()).toBe(1);
186
+ });
187
+ it('should evict least recently used first', async () => {
188
+ const data1 = new Uint8Array(100);
189
+ const data2 = new Uint8Array(100);
190
+ const data3 = new Uint8Array(100);
191
+ data1[0] = 1;
192
+ data2[0] = 2;
193
+ data3[0] = 3;
194
+ const hash1 = makeHash(data1);
195
+ const hash2 = makeHash(data2);
196
+ const hash3 = makeHash(data3);
197
+ // Put in order: 1, 2, 3 with delays to ensure different lastAccess times
198
+ await store.put(hash1, data1);
199
+ await new Promise(r => setTimeout(r, 50));
200
+ await store.put(hash2, data2);
201
+ await new Promise(r => setTimeout(r, 50));
202
+ await store.put(hash3, data3);
203
+ // Access hash1 to make it recently used
204
+ await new Promise(r => setTimeout(r, 50));
205
+ await store.get(hash1);
206
+ // Wait for the fire-and-forget lastAccess update to complete
207
+ await new Promise(r => setTimeout(r, 100));
208
+ // Evict to max 200 bytes - should remove hash2 (oldest after hash1 was accessed)
209
+ await store.evict(200);
210
+ // hash1 and hash3 should remain (most recently accessed/created)
211
+ expect(await store.has(hash1)).toBe(true);
212
+ expect(await store.has(hash2)).toBe(false);
213
+ expect(await store.has(hash3)).toBe(true);
214
+ });
215
+ it('should return 0 when store is empty', async () => {
216
+ const evicted = await store.evict(100);
217
+ expect(evicted).toBe(0);
218
+ });
219
+ });
220
+ });
@@ -0,0 +1 @@
1
+ import 'fake-indexeddb/auto';
@@ -0,0 +1,2 @@
1
+ // Setup fake-indexeddb for Dexie tests
2
+ import 'fake-indexeddb/auto';
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@hashtree/dexie",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "description": "Dexie-based IndexedDB store for hashtree",
9
+ "type": "module",
10
+ "main": "dist/index.js",
11
+ "types": "dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "dependencies": {
22
+ "dexie": "^4.0.11",
23
+ "@hashtree/core": "0.1.0"
24
+ },
25
+ "devDependencies": {
26
+ "fake-indexeddb": "^6.0.0",
27
+ "jsdom": "^26.0.0",
28
+ "typescript": "^5.8.3",
29
+ "vitest": "^2.1.8"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc",
33
+ "clean": "rm -rf dist",
34
+ "test": "vitest run"
35
+ }
36
+ }