@hashtree/dexie 0.1.1 → 0.1.4

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 ADDED
@@ -0,0 +1,49 @@
1
+ # @hashtree/dexie
2
+
3
+ IndexedDB storage adapter for hashtree using Dexie.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @hashtree/dexie
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { DexieStore } from '@hashtree/dexie';
15
+ import { HashTree } from '@hashtree/core';
16
+
17
+ const store = new DexieStore('my-hashtree-db');
18
+ const tree = new HashTree({ store });
19
+
20
+ // Store persists to IndexedDB
21
+ await tree.putFile(data);
22
+ ```
23
+
24
+ ## Features
25
+
26
+ - Persistent browser storage
27
+ - LRU eviction support
28
+ - Automatic schema migrations
29
+
30
+ ## API
31
+
32
+ ```typescript
33
+ const store = new DexieStore(dbName?: string);
34
+
35
+ await store.get(hash);
36
+ await store.put(hash, data);
37
+ await store.has(hash);
38
+ await store.delete(hash);
39
+ await store.keys();
40
+ await store.clear();
41
+ await store.count();
42
+ await store.totalBytes();
43
+ await store.evict(maxBytes);
44
+ store.close();
45
+ ```
46
+
47
+ ## License
48
+
49
+ MIT
package/dist/index.d.ts CHANGED
@@ -5,7 +5,12 @@ import type { Store, Hash } from '@hashtree/core';
5
5
  */
6
6
  export declare class DexieStore implements Store {
7
7
  private db;
8
+ private pendingLastAccessUpdates;
9
+ private lastAccessFlushPromise;
10
+ private lastAccessFlushTimer;
8
11
  constructor(dbName?: string);
12
+ private scheduleLastAccessTouch;
13
+ private flushPendingLastAccessUpdates;
9
14
  put(hash: Hash, data: Uint8Array): Promise<boolean>;
10
15
  get(hash: Hash): Promise<Uint8Array | null>;
11
16
  has(hash: Hash): Promise<boolean>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAkClD;;;GAGG;AACH,qBAAa,UAAW,YAAW,KAAK;IACtC,OAAO,CAAC,EAAE,CAAa;gBAEX,MAAM,GAAE,MAAmB;IAIjC,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC;IAYnD,GAAG,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAyB3C,GAAG,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC;IAYjC,MAAM,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC;IAe1C;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;IAW7B;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ5B;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAS9B;;;OAGG;IACG,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC;IAanC;;;OAGG;IACG,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IA4B9C;;OAEG;IACH,KAAK,IAAI,IAAI;IAIb;;OAEG;WACU,cAAc,CAAC,MAAM,GAAE,MAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAGxE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AA4DlD;;;GAGG;AACH,qBAAa,UAAW,YAAW,KAAK;IACtC,OAAO,CAAC,EAAE,CAAa;IACvB,OAAO,CAAC,wBAAwB,CAA6B;IAC7D,OAAO,CAAC,sBAAsB,CAA8B;IAC5D,OAAO,CAAC,oBAAoB,CAA8C;gBAE9D,MAAM,GAAE,MAAmB;IAIvC,OAAO,CAAC,uBAAuB;YAYjB,6BAA6B;IAyCrC,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC;IAiBnD,GAAG,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAwB3C,GAAG,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC;IAYjC,MAAM,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC;IAmB1C;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;IAW7B;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAY5B;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAS9B;;;OAGG;IACG,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC;IAanC;;;OAGG;IACG,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAiC9C;;OAEG;IACH,KAAK,IAAI,IAAI;IASb;;OAEG;WACU,cAAc,CAAC,MAAM,GAAE,MAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAGxE"}
package/dist/index.js CHANGED
@@ -4,8 +4,10 @@
4
4
  */
5
5
  import Dexie from 'dexie';
6
6
  import { toHex, fromHex } from '@hashtree/core';
7
+ const LAST_ACCESS_FLUSH_DELAY_MS = 50;
7
8
  class HashTreeDB extends Dexie {
8
9
  blobs;
10
+ accesses;
9
11
  constructor(dbName) {
10
12
  super(dbName);
11
13
  // Version 1: Original schema without lastAccess
@@ -22,6 +24,19 @@ class HashTreeDB extends Dexie {
22
24
  blob.lastAccess = now;
23
25
  });
24
26
  });
27
+ // Version 3: Track access timestamps in a separate table so read hits
28
+ // don't need to rewrite blob payload rows.
29
+ this.version(3).stores({
30
+ blobs: '&hashHex, lastAccess',
31
+ accesses: '&hashHex, lastAccess',
32
+ }).upgrade(async (tx) => {
33
+ const blobs = await tx.table('blobs').toArray();
34
+ const accessesTable = tx.table('accesses');
35
+ await Promise.all(blobs.map((blob) => accessesTable.put({
36
+ hashHex: blob.hashHex,
37
+ lastAccess: blob.lastAccess ?? Date.now(),
38
+ })));
39
+ });
25
40
  }
26
41
  }
27
42
  /**
@@ -30,14 +45,63 @@ class HashTreeDB extends Dexie {
30
45
  */
31
46
  export class DexieStore {
32
47
  db;
48
+ pendingLastAccessUpdates = new Map();
49
+ lastAccessFlushPromise = null;
50
+ lastAccessFlushTimer = null;
33
51
  constructor(dbName = 'hashtree') {
34
52
  this.db = new HashTreeDB(dbName);
35
53
  }
54
+ scheduleLastAccessTouch(hashHex) {
55
+ this.pendingLastAccessUpdates.set(hashHex, Date.now());
56
+ if (this.lastAccessFlushTimer !== null) {
57
+ return;
58
+ }
59
+ this.lastAccessFlushTimer = setTimeout(() => {
60
+ this.lastAccessFlushTimer = null;
61
+ void this.flushPendingLastAccessUpdates();
62
+ }, LAST_ACCESS_FLUSH_DELAY_MS);
63
+ }
64
+ async flushPendingLastAccessUpdates() {
65
+ if (this.lastAccessFlushTimer !== null) {
66
+ clearTimeout(this.lastAccessFlushTimer);
67
+ this.lastAccessFlushTimer = null;
68
+ }
69
+ if (this.lastAccessFlushPromise) {
70
+ await this.lastAccessFlushPromise;
71
+ if (this.pendingLastAccessUpdates.size === 0) {
72
+ return;
73
+ }
74
+ }
75
+ if (this.pendingLastAccessUpdates.size === 0) {
76
+ return;
77
+ }
78
+ const updates = Array.from(this.pendingLastAccessUpdates, ([hashHex, lastAccess]) => ({ hashHex, lastAccess }));
79
+ this.pendingLastAccessUpdates.clear();
80
+ const pending = this.db.accesses
81
+ .bulkPut(updates)
82
+ .then(() => undefined)
83
+ .catch((e) => {
84
+ console.error('[DexieStore] lastAccess flush error:', e);
85
+ })
86
+ .finally(() => {
87
+ this.lastAccessFlushPromise = null;
88
+ });
89
+ this.lastAccessFlushPromise = pending;
90
+ await pending;
91
+ if (this.pendingLastAccessUpdates.size > 0) {
92
+ await this.flushPendingLastAccessUpdates();
93
+ }
94
+ }
36
95
  async put(hash, data) {
37
96
  const hashHex = toHex(hash);
97
+ const lastAccess = Date.now();
38
98
  try {
39
- // Store directly - IDB will clone the data internally
40
- await this.db.blobs.put({ hashHex, data, lastAccess: Date.now() });
99
+ await this.flushPendingLastAccessUpdates();
100
+ await this.db.transaction('rw', this.db.blobs, this.db.accesses, async () => {
101
+ // Store directly - IDB will clone the data internally
102
+ await this.db.blobs.put({ hashHex, data, lastAccess });
103
+ await this.db.accesses.put({ hashHex, lastAccess });
104
+ });
41
105
  return true;
42
106
  }
43
107
  catch (e) {
@@ -53,9 +117,8 @@ export class DexieStore {
53
117
  const entry = await this.db.blobs.get(hashHex);
54
118
  if (!entry)
55
119
  return null;
56
- // Update lastAccess timestamp for LRU tracking (fire-and-forget)
57
- // We need to re-put the entry to avoid fake-indexeddb corruption issues with partial updates
58
- this.db.blobs.put({ ...entry, lastAccess: Date.now() }).catch(() => { });
120
+ // Batch LRU touch updates so hot read paths stay read-heavy.
121
+ this.scheduleLastAccessTouch(hashHex);
59
122
  // Return directly - IDB returns a fresh copy already
60
123
  // Only slice if the view doesn't match the buffer (rare edge case)
61
124
  const data = entry.data;
@@ -85,9 +148,13 @@ export class DexieStore {
85
148
  async delete(hash) {
86
149
  const hashHex = toHex(hash);
87
150
  try {
151
+ await this.flushPendingLastAccessUpdates();
88
152
  const existed = await this.has(hash);
89
153
  if (existed) {
90
- await this.db.blobs.delete(hashHex);
154
+ await this.db.transaction('rw', this.db.blobs, this.db.accesses, async () => {
155
+ await this.db.blobs.delete(hashHex);
156
+ await this.db.accesses.delete(hashHex);
157
+ });
91
158
  return true;
92
159
  }
93
160
  return false;
@@ -116,7 +183,11 @@ export class DexieStore {
116
183
  */
117
184
  async clear() {
118
185
  try {
119
- await this.db.blobs.clear();
186
+ await this.flushPendingLastAccessUpdates();
187
+ await this.db.transaction('rw', this.db.blobs, this.db.accesses, async () => {
188
+ await this.db.blobs.clear();
189
+ await this.db.accesses.clear();
190
+ });
120
191
  }
121
192
  catch (e) {
122
193
  console.error('[DexieStore] clear error:', e);
@@ -157,19 +228,24 @@ export class DexieStore {
157
228
  */
158
229
  async evict(maxBytes) {
159
230
  try {
231
+ await this.flushPendingLastAccessUpdates();
160
232
  const currentBytes = await this.totalBytes();
161
233
  if (currentBytes <= maxBytes)
162
234
  return 0;
163
- // Get entries sorted by lastAccess (oldest first)
164
- const entries = await this.db.blobs.orderBy('lastAccess').toArray();
235
+ // Get entries sorted by lastAccess (oldest first) from the lightweight access table.
236
+ const entries = await this.db.accesses.orderBy('lastAccess').toArray();
165
237
  let bytesRemoved = 0;
166
238
  let entriesRemoved = 0;
167
239
  const targetRemoval = currentBytes - maxBytes;
168
240
  for (const entry of entries) {
169
241
  if (bytesRemoved >= targetRemoval)
170
242
  break;
171
- await this.db.blobs.delete(entry.hashHex);
172
- bytesRemoved += entry.data.byteLength;
243
+ const blob = await this.db.blobs.get(entry.hashHex);
244
+ await this.db.transaction('rw', this.db.blobs, this.db.accesses, async () => {
245
+ await this.db.blobs.delete(entry.hashHex);
246
+ await this.db.accesses.delete(entry.hashHex);
247
+ });
248
+ bytesRemoved += blob?.data.byteLength ?? 0;
173
249
  entriesRemoved++;
174
250
  }
175
251
  console.log(`[DexieStore] Evicted ${entriesRemoved} entries (${bytesRemoved} bytes)`);
@@ -184,6 +260,11 @@ export class DexieStore {
184
260
  * Close the database connection
185
261
  */
186
262
  close() {
263
+ if (this.lastAccessFlushTimer !== null) {
264
+ clearTimeout(this.lastAccessFlushTimer);
265
+ this.lastAccessFlushTimer = null;
266
+ }
267
+ this.pendingLastAccessUpdates.clear();
187
268
  this.db.close();
188
269
  }
189
270
  /**
@@ -212,6 +212,28 @@ describe('DexieStore', () => {
212
212
  expect(await store.has(hash2)).toBe(false);
213
213
  expect(await store.has(hash3)).toBe(true);
214
214
  });
215
+ it('should honor a recent get during eviction without waiting for background lastAccess writes', async () => {
216
+ const data1 = new Uint8Array(100);
217
+ const data2 = new Uint8Array(100);
218
+ const data3 = new Uint8Array(100);
219
+ data1[0] = 1;
220
+ data2[0] = 2;
221
+ data3[0] = 3;
222
+ const hash1 = makeHash(data1);
223
+ const hash2 = makeHash(data2);
224
+ const hash3 = makeHash(data3);
225
+ await store.put(hash1, data1);
226
+ await new Promise(r => setTimeout(r, 50));
227
+ await store.put(hash2, data2);
228
+ await new Promise(r => setTimeout(r, 50));
229
+ await store.put(hash3, data3);
230
+ await new Promise(r => setTimeout(r, 50));
231
+ await store.get(hash1);
232
+ await store.evict(200);
233
+ expect(await store.has(hash1)).toBe(true);
234
+ expect(await store.has(hash2)).toBe(false);
235
+ expect(await store.has(hash3)).toBe(true);
236
+ });
215
237
  it('should return 0 when store is empty', async () => {
216
238
  const evicted = await store.evict(100);
217
239
  expect(evicted).toBe(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashtree/dexie",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Dexie-based IndexedDB store for hashtree",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,11 +25,17 @@
25
25
  ],
26
26
  "author": "Martti Malmi",
27
27
  "license": "MIT",
28
+ "repository": "https://git.iris.to/#/npub1xdhnr9mrv47kkrn95k6cwecearydeh8e895990n3acntwvmgk2dsdeeycm/hashtree",
29
+ "homepage": "https://git.iris.to/#/npub1xdhnr9mrv47kkrn95k6cwecearydeh8e895990n3acntwvmgk2dsdeeycm/hashtree/ts/packages/hashtree-dexie",
30
+ "bugs": {
31
+ "url": "https://git.iris.to/#/npub1xdhnr9mrv47kkrn95k6cwecearydeh8e895990n3acntwvmgk2dsdeeycm/hashtree?tab=issues"
32
+ },
28
33
  "dependencies": {
29
34
  "dexie": "^4.2.1",
30
- "@hashtree/core": "0.1.1"
35
+ "@hashtree/core": "0.1.4"
31
36
  },
32
37
  "devDependencies": {
38
+ "fake-indexeddb": "^6.2.5",
33
39
  "typescript": "^5.3.0",
34
40
  "vitest": "^2.0.0"
35
41
  },