1import { createCache } from 'async-cache-dedupe';
2
3const DB_NAME = 'nucleus-cache';
4const STORE_NAME = 'keyvalue';
5const DB_VERSION = 1;
6
7type WriteOp =
8 | {
9 type: 'put';
10 key: string;
11 value: { value: unknown; expires: number };
12 resolve: () => void;
13 reject: (err: unknown) => void;
14 }
15 | { type: 'delete'; key: string; resolve: () => void; reject: (err: unknown) => void };
16type ReadOp = {
17 key: string;
18 resolve: (val: unknown) => void;
19 reject: (err: unknown) => void;
20};
21
22class IDBStorage {
23 private dbPromise: Promise<IDBDatabase> | null = null;
24
25 private getBatch: ReadOp[] = [];
26 private writeBatch: WriteOp[] = [];
27
28 private getFlushScheduled = false;
29 private writeFlushScheduled = false;
30
31 constructor() {
32 if (typeof indexedDB === 'undefined') return;
33
34 this.dbPromise = new Promise((resolve, reject) => {
35 const request = indexedDB.open(DB_NAME, DB_VERSION);
36
37 request.onerror = () => {
38 console.error('IDB open error:', request.error);
39 reject(request.error);
40 };
41
42 request.onsuccess = () => resolve(request.result);
43
44 request.onupgradeneeded = (event) => {
45 const db = (event.target as IDBOpenDBRequest).result;
46 if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
47 };
48 });
49 }
50
51 async get(key: string): Promise<unknown> {
52 // checking in-flight writes
53 for (let i = this.writeBatch.length - 1; i >= 0; i--) {
54 const op = this.writeBatch[i];
55 if (op.key === key) {
56 if (op.type === 'delete') return undefined;
57 if (op.type === 'put') {
58 // if expired we dont want it
59 if (op.value.expires < Date.now()) return undefined;
60 return op.value.value;
61 }
62 }
63 }
64
65 if (!this.dbPromise) return undefined;
66
67 return new Promise((resolve, reject) => {
68 this.getBatch.push({ key, resolve, reject });
69 this.scheduleGetFlush();
70 });
71 }
72
73 private scheduleGetFlush() {
74 if (this.getFlushScheduled) return;
75 this.getFlushScheduled = true;
76 queueMicrotask(() => this.flushGetBatch());
77 }
78
79 private async flushGetBatch() {
80 this.getFlushScheduled = false;
81 const batch = this.getBatch;
82 this.getBatch = [];
83
84 if (batch.length === 0) return;
85
86 try {
87 const db = await this.dbPromise;
88 if (!db) throw new Error('DB not available');
89
90 const transaction = db.transaction(STORE_NAME, 'readonly');
91 const store = transaction.objectStore(STORE_NAME);
92
93 batch.forEach(({ key, resolve }) => {
94 try {
95 const request = store.get(key);
96 request.onsuccess = () => {
97 const result = request.result;
98 if (!result) {
99 resolve(undefined);
100 return;
101 }
102 if (result.expires < Date.now()) {
103 // Fire-and-forget removal for expired items
104 this.remove(key).catch(() => {});
105 resolve(undefined);
106 return;
107 }
108 resolve(result.value);
109 };
110 request.onerror = () => resolve(undefined);
111 } catch {
112 resolve(undefined);
113 }
114 });
115 } catch (error) {
116 batch.forEach(({ reject }) => reject(error));
117 }
118 }
119
120 async set(key: string, value: unknown, ttl: number): Promise<void> {
121 if (!this.dbPromise) return;
122
123 const expires = Date.now() + ttl * 1000;
124 const storageValue = { value, expires };
125
126 return new Promise((resolve, reject) => {
127 this.writeBatch.push({ type: 'put', key, value: storageValue, resolve, reject });
128 this.scheduleWriteFlush();
129 });
130 }
131
132 async remove(key: string): Promise<void> {
133 if (!this.dbPromise) return;
134
135 return new Promise((resolve, reject) => {
136 this.writeBatch.push({ type: 'delete', key, resolve, reject });
137 this.scheduleWriteFlush();
138 });
139 }
140
141 private scheduleWriteFlush() {
142 if (this.writeFlushScheduled) return;
143 this.writeFlushScheduled = true;
144 queueMicrotask(() => this.flushWriteBatch());
145 }
146
147 private async flushWriteBatch() {
148 this.writeFlushScheduled = false;
149 const batch = this.writeBatch;
150 this.writeBatch = [];
151
152 if (batch.length === 0) return;
153
154 try {
155 const db = await this.dbPromise;
156 if (!db) throw new Error('DB not available');
157
158 const transaction = db.transaction(STORE_NAME, 'readwrite');
159 const store = transaction.objectStore(STORE_NAME);
160
161 batch.forEach((op) => {
162 try {
163 let request: IDBRequest;
164 if (op.type === 'put') request = store.put(op.value, op.key);
165 else request = store.delete(op.key);
166
167 request.onsuccess = () => op.resolve();
168 request.onerror = () => op.reject(request.error);
169 } catch (err) {
170 op.reject(err);
171 }
172 });
173 } catch (error) {
174 batch.forEach(({ reject }) => reject(error));
175 }
176 }
177
178 async clear(): Promise<void> {
179 if (!this.dbPromise) return;
180 try {
181 const db = await this.dbPromise;
182 return new Promise<void>((resolve, reject) => {
183 const transaction = db.transaction(STORE_NAME, 'readwrite');
184 const store = transaction.objectStore(STORE_NAME);
185 const request = store.clear();
186
187 request.onerror = () => reject(request.error);
188 request.onsuccess = () => resolve();
189 });
190 } catch (e) {
191 console.error('IDB clear error', e);
192 }
193 }
194
195 async exists(key: string): Promise<boolean> {
196 return (await this.get(key)) !== undefined;
197 }
198
199 async invalidate(key: string): Promise<void> {
200 return this.remove(key);
201 }
202
203 // noops
204 // eslint-disable-next-line @typescript-eslint/no-unused-vars
205 async getTTL(key: string): Promise<void> {
206 return;
207 }
208 async refresh(): Promise<void> {
209 return;
210 }
211}
212
213export const ttl = 60 * 60 * 3; // 3 hours
214
215export const cache = createCache({
216 storage: {
217 type: 'custom',
218 options: {
219 storage: new IDBStorage()
220 }
221 },
222 ttl,
223 onError: (err) => console.error(err)
224});