@dynlabs/react-native-immutable-file-cache 1.0.0-alpha.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/LICENSE +21 -0
- package/README.md +415 -0
- package/lib/commonjs/adapters/memoryAdapter.js +266 -0
- package/lib/commonjs/adapters/memoryAdapter.js.map +1 -0
- package/lib/commonjs/adapters/rnfsAdapter.js +259 -0
- package/lib/commonjs/adapters/rnfsAdapter.js.map +1 -0
- package/lib/commonjs/adapters/webAdapter.js +432 -0
- package/lib/commonjs/adapters/webAdapter.js.map +1 -0
- package/lib/commonjs/core/adapter.js +2 -0
- package/lib/commonjs/core/adapter.js.map +1 -0
- package/lib/commonjs/core/cacheEngine.js +578 -0
- package/lib/commonjs/core/cacheEngine.js.map +1 -0
- package/lib/commonjs/core/errors.js +83 -0
- package/lib/commonjs/core/errors.js.map +1 -0
- package/lib/commonjs/core/hash.js +83 -0
- package/lib/commonjs/core/hash.js.map +1 -0
- package/lib/commonjs/core/indexStore.js +175 -0
- package/lib/commonjs/core/indexStore.js.map +1 -0
- package/lib/commonjs/core/mutex.js +143 -0
- package/lib/commonjs/core/mutex.js.map +1 -0
- package/lib/commonjs/core/prune.js +127 -0
- package/lib/commonjs/core/prune.js.map +1 -0
- package/lib/commonjs/core/types.js +6 -0
- package/lib/commonjs/core/types.js.map +1 -0
- package/lib/commonjs/factory.js +56 -0
- package/lib/commonjs/factory.js.map +1 -0
- package/lib/commonjs/index.js +110 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/index.native.js +74 -0
- package/lib/commonjs/index.native.js.map +1 -0
- package/lib/commonjs/index.web.js +75 -0
- package/lib/commonjs/index.web.js.map +1 -0
- package/lib/commonjs/types/react-native-fs.d.js +2 -0
- package/lib/commonjs/types/react-native-fs.d.js.map +1 -0
- package/lib/module/adapters/memoryAdapter.js +261 -0
- package/lib/module/adapters/memoryAdapter.js.map +1 -0
- package/lib/module/adapters/rnfsAdapter.js +251 -0
- package/lib/module/adapters/rnfsAdapter.js.map +1 -0
- package/lib/module/adapters/webAdapter.js +426 -0
- package/lib/module/adapters/webAdapter.js.map +1 -0
- package/lib/module/core/adapter.js +2 -0
- package/lib/module/core/adapter.js.map +1 -0
- package/lib/module/core/cacheEngine.js +571 -0
- package/lib/module/core/cacheEngine.js.map +1 -0
- package/lib/module/core/errors.js +71 -0
- package/lib/module/core/errors.js.map +1 -0
- package/lib/module/core/hash.js +76 -0
- package/lib/module/core/hash.js.map +1 -0
- package/lib/module/core/indexStore.js +168 -0
- package/lib/module/core/indexStore.js.map +1 -0
- package/lib/module/core/mutex.js +135 -0
- package/lib/module/core/mutex.js.map +1 -0
- package/lib/module/core/prune.js +116 -0
- package/lib/module/core/prune.js.map +1 -0
- package/lib/module/core/types.js +2 -0
- package/lib/module/core/types.js.map +1 -0
- package/lib/module/factory.js +49 -0
- package/lib/module/factory.js.map +1 -0
- package/lib/module/index.js +41 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/index.native.js +54 -0
- package/lib/module/index.native.js.map +1 -0
- package/lib/module/index.web.js +55 -0
- package/lib/module/index.web.js.map +1 -0
- package/lib/module/types/react-native-fs.d.js +2 -0
- package/lib/module/types/react-native-fs.d.js.map +1 -0
- package/lib/typescript/src/adapters/memoryAdapter.d.ts +23 -0
- package/lib/typescript/src/adapters/memoryAdapter.d.ts.map +1 -0
- package/lib/typescript/src/adapters/rnfsAdapter.d.ts +18 -0
- package/lib/typescript/src/adapters/rnfsAdapter.d.ts.map +1 -0
- package/lib/typescript/src/adapters/webAdapter.d.ts +30 -0
- package/lib/typescript/src/adapters/webAdapter.d.ts.map +1 -0
- package/lib/typescript/src/core/adapter.d.ts +105 -0
- package/lib/typescript/src/core/adapter.d.ts.map +1 -0
- package/lib/typescript/src/core/cacheEngine.d.ts +99 -0
- package/lib/typescript/src/core/cacheEngine.d.ts.map +1 -0
- package/lib/typescript/src/core/errors.d.ts +54 -0
- package/lib/typescript/src/core/errors.d.ts.map +1 -0
- package/lib/typescript/src/core/hash.d.ts +20 -0
- package/lib/typescript/src/core/hash.d.ts.map +1 -0
- package/lib/typescript/src/core/indexStore.d.ts +34 -0
- package/lib/typescript/src/core/indexStore.d.ts.map +1 -0
- package/lib/typescript/src/core/mutex.d.ts +49 -0
- package/lib/typescript/src/core/mutex.d.ts.map +1 -0
- package/lib/typescript/src/core/prune.d.ts +39 -0
- package/lib/typescript/src/core/prune.d.ts.map +1 -0
- package/lib/typescript/src/core/types.d.ts +109 -0
- package/lib/typescript/src/core/types.d.ts.map +1 -0
- package/lib/typescript/src/factory.d.ts +46 -0
- package/lib/typescript/src/factory.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +20 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/index.native.d.ts +37 -0
- package/lib/typescript/src/index.native.d.ts.map +1 -0
- package/lib/typescript/src/index.web.d.ts +38 -0
- package/lib/typescript/src/index.web.d.ts.map +1 -0
- package/package.json +125 -0
- package/src/adapters/memoryAdapter.ts +307 -0
- package/src/adapters/rnfsAdapter.ts +283 -0
- package/src/adapters/webAdapter.ts +480 -0
- package/src/core/adapter.ts +128 -0
- package/src/core/cacheEngine.ts +634 -0
- package/src/core/errors.ts +82 -0
- package/src/core/hash.ts +78 -0
- package/src/core/indexStore.ts +184 -0
- package/src/core/mutex.ts +134 -0
- package/src/core/prune.ts +145 -0
- package/src/core/types.ts +165 -0
- package/src/factory.ts +60 -0
- package/src/index.native.ts +58 -0
- package/src/index.ts +82 -0
- package/src/index.web.ts +59 -0
- package/src/types/react-native-fs.d.ts +75 -0
package/src/core/hash.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform SHA-256 hashing utility.
|
|
3
|
+
* Uses SubtleCrypto on web and native platforms that support it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Converts an ArrayBuffer to a lowercase hex string.
|
|
8
|
+
*/
|
|
9
|
+
function arrayBufferToHex(buffer: ArrayBuffer): string {
|
|
10
|
+
const bytes = new Uint8Array(buffer);
|
|
11
|
+
let hex = "";
|
|
12
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
13
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
14
|
+
}
|
|
15
|
+
return hex;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Converts a string to a Uint8Array using UTF-8 encoding.
|
|
20
|
+
*/
|
|
21
|
+
function stringToUint8Array(str: string): Uint8Array {
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
return encoder.encode(str);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Computes SHA-256 hash of the input string.
|
|
28
|
+
* Returns lowercase hex string.
|
|
29
|
+
*
|
|
30
|
+
* Uses SubtleCrypto API which is available in:
|
|
31
|
+
* - Modern browsers
|
|
32
|
+
* - React Native (via Hermes or polyfill)
|
|
33
|
+
* - Node.js 15+
|
|
34
|
+
*/
|
|
35
|
+
export async function hash(input: string): Promise<string> {
|
|
36
|
+
// Use SubtleCrypto (available in browsers and modern RN)
|
|
37
|
+
if (typeof globalThis.crypto?.subtle?.digest === "function") {
|
|
38
|
+
const data = stringToUint8Array(input);
|
|
39
|
+
const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", data.buffer as ArrayBuffer);
|
|
40
|
+
return arrayBufferToHex(hashBuffer);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fallback: Simple hash for environments without crypto
|
|
44
|
+
// This is a basic djb2-based hash - NOT cryptographically secure
|
|
45
|
+
// Should only be used as last resort fallback
|
|
46
|
+
return fallbackHash(input);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Simple non-cryptographic hash fallback.
|
|
51
|
+
* Used only when SubtleCrypto is unavailable.
|
|
52
|
+
*/
|
|
53
|
+
function fallbackHash(input: string): string {
|
|
54
|
+
let h1 = 0xdeadbeef;
|
|
55
|
+
let h2 = 0x41c6ce57;
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < input.length; i++) {
|
|
58
|
+
const ch = input.charCodeAt(i);
|
|
59
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
60
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
|
|
64
|
+
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
65
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
|
|
66
|
+
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
67
|
+
|
|
68
|
+
const result = 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
|
69
|
+
return result.toString(16).padStart(16, "0");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Synchronous hash for cases where async is not possible.
|
|
74
|
+
* Uses fallback algorithm - prefer async hash() when possible.
|
|
75
|
+
*/
|
|
76
|
+
export function hashSync(input: string): string {
|
|
77
|
+
return fallbackHash(input);
|
|
78
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { IStorageAdapter, TAdapterPath } from "./adapter";
|
|
2
|
+
import type { ICacheIndex, ICacheEntryMeta } from "./types";
|
|
3
|
+
import { CorruptIndexError, AdapterIOError } from "./errors";
|
|
4
|
+
import { createEmptyIndex } from "./prune";
|
|
5
|
+
|
|
6
|
+
const INDEX_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages persistence of the cache index via the storage adapter.
|
|
10
|
+
*/
|
|
11
|
+
export class IndexStore {
|
|
12
|
+
private readonly _adapter: IStorageAdapter;
|
|
13
|
+
private readonly _indexPath: TAdapterPath;
|
|
14
|
+
|
|
15
|
+
constructor(adapter: IStorageAdapter, indexPath: TAdapterPath) {
|
|
16
|
+
this._adapter = adapter;
|
|
17
|
+
this._indexPath = indexPath;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load index from storage.
|
|
22
|
+
* Returns empty index if file doesn't exist.
|
|
23
|
+
* Throws CorruptIndexError if index is malformed.
|
|
24
|
+
*/
|
|
25
|
+
async load(): Promise<ICacheIndex> {
|
|
26
|
+
try {
|
|
27
|
+
const exists = await this._adapter.exists(this._indexPath);
|
|
28
|
+
if (!exists) {
|
|
29
|
+
return createEmptyIndex();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const content = await this._adapter.readText(this._indexPath, "utf8");
|
|
33
|
+
const parsed = JSON.parse(content) as unknown;
|
|
34
|
+
|
|
35
|
+
return this._validateIndex(parsed);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof CorruptIndexError) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
if (error instanceof SyntaxError) {
|
|
41
|
+
throw new CorruptIndexError("Invalid JSON", error);
|
|
42
|
+
}
|
|
43
|
+
// File doesn't exist or other read error - return empty
|
|
44
|
+
if (error instanceof AdapterIOError) {
|
|
45
|
+
return createEmptyIndex();
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Save index atomically via the adapter.
|
|
53
|
+
*/
|
|
54
|
+
async save(index: ICacheIndex): Promise<void> {
|
|
55
|
+
const content = JSON.stringify(index, null, 2);
|
|
56
|
+
await this._adapter.writeTextAtomic(this._indexPath, content, "utf8");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate and rebuild index from filesystem if corrupt.
|
|
61
|
+
* Scans the entries directory and reconstructs metadata.
|
|
62
|
+
*/
|
|
63
|
+
async rebuild(entriesDir: TAdapterPath): Promise<ICacheIndex> {
|
|
64
|
+
const entries: Record<string, ICacheEntryMeta> = {};
|
|
65
|
+
let totalSizeBytes = 0;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const files = await this._adapter.listDir(entriesDir);
|
|
69
|
+
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
try {
|
|
72
|
+
const filePath = `${entriesDir}/${file}`;
|
|
73
|
+
const stat = await this._adapter.stat(filePath);
|
|
74
|
+
|
|
75
|
+
// Extract hash and extension from filename
|
|
76
|
+
const lastDot = file.lastIndexOf(".");
|
|
77
|
+
const hash = lastDot > 0 ? file.substring(0, lastDot) : file;
|
|
78
|
+
const ext = lastDot > 0 ? file.substring(lastDot) : "";
|
|
79
|
+
|
|
80
|
+
// Create minimal entry metadata
|
|
81
|
+
// Note: We lose the original key during rebuild
|
|
82
|
+
const entry: ICacheEntryMeta = {
|
|
83
|
+
key: hash, // Use hash as key since original is lost
|
|
84
|
+
hash,
|
|
85
|
+
ext,
|
|
86
|
+
sizeBytes: stat.sizeBytes,
|
|
87
|
+
createdAt: stat.mtimeMs,
|
|
88
|
+
lastAccessedAt: stat.mtimeMs,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
entries[hash] = entry;
|
|
92
|
+
totalSizeBytes += stat.sizeBytes;
|
|
93
|
+
} catch {
|
|
94
|
+
// Skip files that can't be stat'd
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// If we can't list the directory, return empty index
|
|
100
|
+
return createEmptyIndex();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rebuiltIndex: ICacheIndex = {
|
|
104
|
+
version: INDEX_VERSION,
|
|
105
|
+
entries,
|
|
106
|
+
totalSizeBytes,
|
|
107
|
+
lastModifiedAt: Date.now(),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Save the rebuilt index
|
|
111
|
+
await this.save(rebuiltIndex);
|
|
112
|
+
|
|
113
|
+
return rebuiltIndex;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validates that the parsed object is a valid ICacheIndex.
|
|
118
|
+
*/
|
|
119
|
+
private _validateIndex(parsed: unknown): ICacheIndex {
|
|
120
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
121
|
+
throw new CorruptIndexError("Index is not an object");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const obj = parsed as Record<string, unknown>;
|
|
125
|
+
|
|
126
|
+
// Check version
|
|
127
|
+
if (obj.version !== INDEX_VERSION) {
|
|
128
|
+
throw new CorruptIndexError(`Unsupported version: ${String(obj.version)}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check entries
|
|
132
|
+
if (typeof obj.entries !== "object" || obj.entries === null) {
|
|
133
|
+
throw new CorruptIndexError("Entries is not an object");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Validate each entry has required fields
|
|
137
|
+
const entries = obj.entries as Record<string, unknown>;
|
|
138
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
139
|
+
this._validateEntry(key, entry);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check totalSizeBytes
|
|
143
|
+
if (typeof obj.totalSizeBytes !== "number" || obj.totalSizeBytes < 0) {
|
|
144
|
+
throw new CorruptIndexError("Invalid totalSizeBytes");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check lastModifiedAt
|
|
148
|
+
if (typeof obj.lastModifiedAt !== "number") {
|
|
149
|
+
throw new CorruptIndexError("Invalid lastModifiedAt");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
version: INDEX_VERSION,
|
|
154
|
+
entries: entries as Record<string, ICacheEntryMeta>,
|
|
155
|
+
totalSizeBytes: obj.totalSizeBytes,
|
|
156
|
+
lastModifiedAt: obj.lastModifiedAt,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Validates that an entry has all required fields.
|
|
162
|
+
*/
|
|
163
|
+
private _validateEntry(key: string, entry: unknown): void {
|
|
164
|
+
if (typeof entry !== "object" || entry === null) {
|
|
165
|
+
throw new CorruptIndexError(`Entry "${key}" is not an object`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const obj = entry as Record<string, unknown>;
|
|
169
|
+
const requiredStrings = ["key", "hash", "ext"];
|
|
170
|
+
const requiredNumbers = ["sizeBytes", "createdAt", "lastAccessedAt"];
|
|
171
|
+
|
|
172
|
+
for (const field of requiredStrings) {
|
|
173
|
+
if (typeof obj[field] !== "string") {
|
|
174
|
+
throw new CorruptIndexError(`Entry "${key}" missing string field "${field}"`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const field of requiredNumbers) {
|
|
179
|
+
if (typeof obj[field] !== "number") {
|
|
180
|
+
throw new CorruptIndexError(`Entry "${key}" missing number field "${field}"`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async mutex for serializing critical sections.
|
|
3
|
+
* Ensures only one operation runs at a time within a critical section.
|
|
4
|
+
*/
|
|
5
|
+
export class Mutex {
|
|
6
|
+
private readonly _queue: Array<() => void> = [];
|
|
7
|
+
private _locked = false;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Acquires the mutex lock.
|
|
11
|
+
* Returns a release function that must be called when done.
|
|
12
|
+
*/
|
|
13
|
+
async acquire(): Promise<() => void> {
|
|
14
|
+
return new Promise<() => void>((resolve) => {
|
|
15
|
+
const tryAcquire = (): void => {
|
|
16
|
+
if (!this._locked) {
|
|
17
|
+
this._locked = true;
|
|
18
|
+
resolve(this._createRelease());
|
|
19
|
+
} else {
|
|
20
|
+
this._queue.push(tryAcquire);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
tryAcquire();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Creates a release function for the current lock holder.
|
|
29
|
+
*/
|
|
30
|
+
private _createRelease(): () => void {
|
|
31
|
+
let released = false;
|
|
32
|
+
return (): void => {
|
|
33
|
+
if (released) {
|
|
34
|
+
return; // Prevent double-release
|
|
35
|
+
}
|
|
36
|
+
released = true;
|
|
37
|
+
this._locked = false;
|
|
38
|
+
const next = this._queue.shift();
|
|
39
|
+
if (next) {
|
|
40
|
+
next();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns whether the mutex is currently locked.
|
|
47
|
+
*/
|
|
48
|
+
get isLocked(): boolean {
|
|
49
|
+
return this._locked;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Executes a function with the mutex held.
|
|
54
|
+
* Automatically releases the mutex when done.
|
|
55
|
+
*/
|
|
56
|
+
async runExclusive<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
57
|
+
const release = await this.acquire();
|
|
58
|
+
try {
|
|
59
|
+
return await fn();
|
|
60
|
+
} finally {
|
|
61
|
+
release();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Keyed mutex for per-key locking.
|
|
68
|
+
* Allows concurrent operations on different keys while serializing same-key operations.
|
|
69
|
+
*/
|
|
70
|
+
export class KeyedMutex {
|
|
71
|
+
private readonly _mutexes = new Map<string, Mutex>();
|
|
72
|
+
private readonly _refCounts = new Map<string, number>();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Acquires lock for a specific key.
|
|
76
|
+
* Returns a release function that must be called when done.
|
|
77
|
+
*/
|
|
78
|
+
async acquire(key: string): Promise<() => void> {
|
|
79
|
+
// Get or create mutex for this key
|
|
80
|
+
let mutex = this._mutexes.get(key);
|
|
81
|
+
if (!mutex) {
|
|
82
|
+
mutex = new Mutex();
|
|
83
|
+
this._mutexes.set(key, mutex);
|
|
84
|
+
this._refCounts.set(key, 0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Increment ref count
|
|
88
|
+
this._refCounts.set(key, (this._refCounts.get(key) ?? 0) + 1);
|
|
89
|
+
|
|
90
|
+
// Acquire the mutex
|
|
91
|
+
const innerRelease = await mutex.acquire();
|
|
92
|
+
|
|
93
|
+
// Return wrapped release that cleans up when ref count hits 0
|
|
94
|
+
let released = false;
|
|
95
|
+
return (): void => {
|
|
96
|
+
if (released) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
released = true;
|
|
100
|
+
|
|
101
|
+
// Release the inner mutex
|
|
102
|
+
innerRelease();
|
|
103
|
+
|
|
104
|
+
// Decrement ref count and clean up if zero
|
|
105
|
+
const count = (this._refCounts.get(key) ?? 1) - 1;
|
|
106
|
+
if (count <= 0) {
|
|
107
|
+
this._mutexes.delete(key);
|
|
108
|
+
this._refCounts.delete(key);
|
|
109
|
+
} else {
|
|
110
|
+
this._refCounts.set(key, count);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Executes a function with the keyed mutex held.
|
|
117
|
+
* Automatically releases the mutex when done.
|
|
118
|
+
*/
|
|
119
|
+
async runExclusive<T>(key: string, fn: () => Promise<T> | T): Promise<T> {
|
|
120
|
+
const release = await this.acquire(key);
|
|
121
|
+
try {
|
|
122
|
+
return await fn();
|
|
123
|
+
} finally {
|
|
124
|
+
release();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Returns the number of active keys with pending operations.
|
|
130
|
+
*/
|
|
131
|
+
get activeKeyCount(): number {
|
|
132
|
+
return this._mutexes.size;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ICacheIndex, ICacheEntryMeta } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Options for pruning operations.
|
|
5
|
+
*/
|
|
6
|
+
export interface IPruneOptions {
|
|
7
|
+
readonly maxSizeBytes?: number;
|
|
8
|
+
readonly now?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Identifies entries to remove based on TTL expiration.
|
|
13
|
+
* Returns entries that have expired (expiresAt < now).
|
|
14
|
+
*/
|
|
15
|
+
export function getExpiredEntries(
|
|
16
|
+
index: ICacheIndex,
|
|
17
|
+
now: number = Date.now()
|
|
18
|
+
): ReadonlyArray<ICacheEntryMeta> {
|
|
19
|
+
const expired: ICacheEntryMeta[] = [];
|
|
20
|
+
|
|
21
|
+
for (const entry of Object.values(index.entries)) {
|
|
22
|
+
if (entry.expiresAt !== undefined && entry.expiresAt < now) {
|
|
23
|
+
expired.push(entry);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return expired;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Identifies entries to remove based on LRU policy to meet size limit.
|
|
32
|
+
* Sorts entries by lastAccessedAt (oldest first) and returns entries
|
|
33
|
+
* that need to be removed to get under maxSizeBytes.
|
|
34
|
+
*/
|
|
35
|
+
export function getLruPruneTargets(
|
|
36
|
+
index: ICacheIndex,
|
|
37
|
+
maxSizeBytes: number
|
|
38
|
+
): ReadonlyArray<ICacheEntryMeta> {
|
|
39
|
+
if (index.totalSizeBytes <= maxSizeBytes) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Sort entries by lastAccessedAt ascending (oldest first = LRU candidates)
|
|
44
|
+
const sortedEntries = Object.values(index.entries).sort(
|
|
45
|
+
(a, b) => a.lastAccessedAt - b.lastAccessedAt
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const toRemove: ICacheEntryMeta[] = [];
|
|
49
|
+
let currentSize = index.totalSizeBytes;
|
|
50
|
+
|
|
51
|
+
for (const entry of sortedEntries) {
|
|
52
|
+
if (currentSize <= maxSizeBytes) {
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
toRemove.push(entry);
|
|
56
|
+
currentSize -= entry.sizeBytes;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return toRemove;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates a new index with the specified entries removed.
|
|
64
|
+
* Returns a new ICacheIndex without mutating the original.
|
|
65
|
+
*/
|
|
66
|
+
export function removeEntriesFromIndex(
|
|
67
|
+
index: ICacheIndex,
|
|
68
|
+
keysToRemove: ReadonlyArray<string>
|
|
69
|
+
): ICacheIndex {
|
|
70
|
+
const keySet = new Set(keysToRemove);
|
|
71
|
+
const newEntries: Record<string, ICacheEntryMeta> = {};
|
|
72
|
+
let newTotalSize = 0;
|
|
73
|
+
|
|
74
|
+
for (const [key, entry] of Object.entries(index.entries)) {
|
|
75
|
+
if (!keySet.has(key)) {
|
|
76
|
+
newEntries[key] = entry;
|
|
77
|
+
newTotalSize += entry.sizeBytes;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
version: 1,
|
|
83
|
+
entries: newEntries,
|
|
84
|
+
totalSizeBytes: newTotalSize,
|
|
85
|
+
lastModifiedAt: Date.now(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Adds or updates an entry in the index.
|
|
91
|
+
* Returns a new ICacheIndex without mutating the original.
|
|
92
|
+
*/
|
|
93
|
+
export function addEntryToIndex(index: ICacheIndex, entry: ICacheEntryMeta): ICacheIndex {
|
|
94
|
+
const existingEntry = index.entries[entry.key];
|
|
95
|
+
const sizeDelta = entry.sizeBytes - (existingEntry?.sizeBytes ?? 0);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
version: 1,
|
|
99
|
+
entries: {
|
|
100
|
+
...index.entries,
|
|
101
|
+
[entry.key]: entry,
|
|
102
|
+
},
|
|
103
|
+
totalSizeBytes: index.totalSizeBytes + sizeDelta,
|
|
104
|
+
lastModifiedAt: Date.now(),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Updates the lastAccessedAt timestamp for an entry.
|
|
110
|
+
* Returns a new ICacheIndex without mutating the original.
|
|
111
|
+
*/
|
|
112
|
+
export function touchEntry(
|
|
113
|
+
index: ICacheIndex,
|
|
114
|
+
key: string,
|
|
115
|
+
accessedAt: number = Date.now()
|
|
116
|
+
): ICacheIndex {
|
|
117
|
+
const entry = index.entries[key];
|
|
118
|
+
if (!entry) {
|
|
119
|
+
return index;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
...index,
|
|
124
|
+
entries: {
|
|
125
|
+
...index.entries,
|
|
126
|
+
[key]: {
|
|
127
|
+
...entry,
|
|
128
|
+
lastAccessedAt: accessedAt,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
lastModifiedAt: accessedAt,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Creates an empty cache index.
|
|
137
|
+
*/
|
|
138
|
+
export function createEmptyIndex(): ICacheIndex {
|
|
139
|
+
return {
|
|
140
|
+
version: 1,
|
|
141
|
+
entries: {},
|
|
142
|
+
totalSizeBytes: 0,
|
|
143
|
+
lastModifiedAt: Date.now(),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { IStorageAdapter, TAdapterPath } from "./adapter";
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────
|
|
4
|
+
// Cache Configuration
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface ICacheConfig {
|
|
8
|
+
/**
|
|
9
|
+
* Namespace for cache isolation. Creates separate storage area.
|
|
10
|
+
* @default "default"
|
|
11
|
+
*/
|
|
12
|
+
readonly namespace?: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Custom storage adapter. If not provided, auto-selects based on platform.
|
|
16
|
+
*/
|
|
17
|
+
readonly adapter?: IStorageAdapter;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Default TTL in milliseconds for new entries.
|
|
21
|
+
* @default undefined (no expiration)
|
|
22
|
+
*/
|
|
23
|
+
readonly defaultTtlMs?: number;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maximum cache size in bytes. Triggers LRU pruning when exceeded.
|
|
27
|
+
* @default undefined (no limit)
|
|
28
|
+
*/
|
|
29
|
+
readonly maxSizeBytes?: number;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether to auto-prune expired entries on cache operations.
|
|
33
|
+
* @default true
|
|
34
|
+
*/
|
|
35
|
+
readonly autoPruneExpired?: boolean;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Custom hash function for generating cache keys from input keys.
|
|
39
|
+
* @default SHA-256 hex
|
|
40
|
+
*/
|
|
41
|
+
readonly hashFn?: (input: string) => string | Promise<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────
|
|
45
|
+
// Cache Entry
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export interface ICacheEntryMeta {
|
|
49
|
+
/** User-provided unique key. */
|
|
50
|
+
readonly key: string;
|
|
51
|
+
|
|
52
|
+
/** Hash of the key (used for filename). */
|
|
53
|
+
readonly hash: string;
|
|
54
|
+
|
|
55
|
+
/** Original file extension (e.g., ".jpg"). */
|
|
56
|
+
readonly ext: string;
|
|
57
|
+
|
|
58
|
+
/** Size in bytes. */
|
|
59
|
+
readonly sizeBytes: number;
|
|
60
|
+
|
|
61
|
+
/** MIME content type if known. */
|
|
62
|
+
readonly contentType?: string;
|
|
63
|
+
|
|
64
|
+
/** Timestamp when entry was created (ms since epoch). */
|
|
65
|
+
readonly createdAt: number;
|
|
66
|
+
|
|
67
|
+
/** Timestamp when entry was last accessed (ms since epoch). */
|
|
68
|
+
readonly lastAccessedAt: number;
|
|
69
|
+
|
|
70
|
+
/** Expiration timestamp (ms since epoch). undefined = never expires. */
|
|
71
|
+
readonly expiresAt?: number;
|
|
72
|
+
|
|
73
|
+
/** Optional user-defined metadata. */
|
|
74
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ICacheEntry extends ICacheEntryMeta {
|
|
78
|
+
/** Adapter-relative path to the cached file. */
|
|
79
|
+
readonly path: TAdapterPath;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─────────────────────────────────────────────────────────────────
|
|
83
|
+
// Cache Index
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export interface ICacheIndex {
|
|
87
|
+
readonly version: 1;
|
|
88
|
+
readonly entries: Readonly<Record<string, ICacheEntryMeta>>;
|
|
89
|
+
readonly totalSizeBytes: number;
|
|
90
|
+
readonly lastModifiedAt: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─────────────────────────────────────────────────────────────────
|
|
94
|
+
// Put Operations
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export interface IPutOptions {
|
|
98
|
+
/** TTL in milliseconds. Overrides defaultTtlMs. */
|
|
99
|
+
readonly ttlMs?: number;
|
|
100
|
+
|
|
101
|
+
/** File extension to use (e.g., ".jpg"). Auto-detected if omitted. */
|
|
102
|
+
readonly ext?: string;
|
|
103
|
+
|
|
104
|
+
/** Optional user-defined metadata. */
|
|
105
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
106
|
+
|
|
107
|
+
/** Progress callback for download/write operations. */
|
|
108
|
+
readonly onProgress?: (percent: number) => void;
|
|
109
|
+
|
|
110
|
+
/** Additional headers for URL fetches. */
|
|
111
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type TPutStatus = "created" | "exists";
|
|
115
|
+
|
|
116
|
+
export interface IPutResult {
|
|
117
|
+
readonly status: TPutStatus;
|
|
118
|
+
readonly entry: ICacheEntry;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────
|
|
122
|
+
// Get/List Operations
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export interface IGetResult {
|
|
126
|
+
readonly entry: ICacheEntry;
|
|
127
|
+
readonly uri: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type TSortField = "createdAt" | "lastAccessedAt" | "sizeBytes" | "key";
|
|
131
|
+
export type TSortOrder = "asc" | "desc";
|
|
132
|
+
|
|
133
|
+
export interface IListOptions {
|
|
134
|
+
/** Sort field. @default "createdAt" */
|
|
135
|
+
readonly sortBy?: TSortField;
|
|
136
|
+
|
|
137
|
+
/** Sort order. @default "desc" */
|
|
138
|
+
readonly order?: TSortOrder;
|
|
139
|
+
|
|
140
|
+
/** Maximum entries to return. */
|
|
141
|
+
readonly limit?: number;
|
|
142
|
+
|
|
143
|
+
/** Number of entries to skip. */
|
|
144
|
+
readonly offset?: number;
|
|
145
|
+
|
|
146
|
+
/** Filter by metadata. */
|
|
147
|
+
readonly filter?: (entry: ICacheEntryMeta) => boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─────────────────────────────────────────────────────────────────
|
|
151
|
+
// Prune/Stats Operations
|
|
152
|
+
// ─────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
export interface IPruneResult {
|
|
155
|
+
readonly removedCount: number;
|
|
156
|
+
readonly freedBytes: number;
|
|
157
|
+
readonly removedKeys: ReadonlyArray<string>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface ICacheStats {
|
|
161
|
+
readonly entryCount: number;
|
|
162
|
+
readonly totalSizeBytes: number;
|
|
163
|
+
readonly oldestEntry?: ICacheEntryMeta;
|
|
164
|
+
readonly newestEntry?: ICacheEntryMeta;
|
|
165
|
+
}
|