@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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +415 -0
  3. package/lib/commonjs/adapters/memoryAdapter.js +266 -0
  4. package/lib/commonjs/adapters/memoryAdapter.js.map +1 -0
  5. package/lib/commonjs/adapters/rnfsAdapter.js +259 -0
  6. package/lib/commonjs/adapters/rnfsAdapter.js.map +1 -0
  7. package/lib/commonjs/adapters/webAdapter.js +432 -0
  8. package/lib/commonjs/adapters/webAdapter.js.map +1 -0
  9. package/lib/commonjs/core/adapter.js +2 -0
  10. package/lib/commonjs/core/adapter.js.map +1 -0
  11. package/lib/commonjs/core/cacheEngine.js +578 -0
  12. package/lib/commonjs/core/cacheEngine.js.map +1 -0
  13. package/lib/commonjs/core/errors.js +83 -0
  14. package/lib/commonjs/core/errors.js.map +1 -0
  15. package/lib/commonjs/core/hash.js +83 -0
  16. package/lib/commonjs/core/hash.js.map +1 -0
  17. package/lib/commonjs/core/indexStore.js +175 -0
  18. package/lib/commonjs/core/indexStore.js.map +1 -0
  19. package/lib/commonjs/core/mutex.js +143 -0
  20. package/lib/commonjs/core/mutex.js.map +1 -0
  21. package/lib/commonjs/core/prune.js +127 -0
  22. package/lib/commonjs/core/prune.js.map +1 -0
  23. package/lib/commonjs/core/types.js +6 -0
  24. package/lib/commonjs/core/types.js.map +1 -0
  25. package/lib/commonjs/factory.js +56 -0
  26. package/lib/commonjs/factory.js.map +1 -0
  27. package/lib/commonjs/index.js +110 -0
  28. package/lib/commonjs/index.js.map +1 -0
  29. package/lib/commonjs/index.native.js +74 -0
  30. package/lib/commonjs/index.native.js.map +1 -0
  31. package/lib/commonjs/index.web.js +75 -0
  32. package/lib/commonjs/index.web.js.map +1 -0
  33. package/lib/commonjs/types/react-native-fs.d.js +2 -0
  34. package/lib/commonjs/types/react-native-fs.d.js.map +1 -0
  35. package/lib/module/adapters/memoryAdapter.js +261 -0
  36. package/lib/module/adapters/memoryAdapter.js.map +1 -0
  37. package/lib/module/adapters/rnfsAdapter.js +251 -0
  38. package/lib/module/adapters/rnfsAdapter.js.map +1 -0
  39. package/lib/module/adapters/webAdapter.js +426 -0
  40. package/lib/module/adapters/webAdapter.js.map +1 -0
  41. package/lib/module/core/adapter.js +2 -0
  42. package/lib/module/core/adapter.js.map +1 -0
  43. package/lib/module/core/cacheEngine.js +571 -0
  44. package/lib/module/core/cacheEngine.js.map +1 -0
  45. package/lib/module/core/errors.js +71 -0
  46. package/lib/module/core/errors.js.map +1 -0
  47. package/lib/module/core/hash.js +76 -0
  48. package/lib/module/core/hash.js.map +1 -0
  49. package/lib/module/core/indexStore.js +168 -0
  50. package/lib/module/core/indexStore.js.map +1 -0
  51. package/lib/module/core/mutex.js +135 -0
  52. package/lib/module/core/mutex.js.map +1 -0
  53. package/lib/module/core/prune.js +116 -0
  54. package/lib/module/core/prune.js.map +1 -0
  55. package/lib/module/core/types.js +2 -0
  56. package/lib/module/core/types.js.map +1 -0
  57. package/lib/module/factory.js +49 -0
  58. package/lib/module/factory.js.map +1 -0
  59. package/lib/module/index.js +41 -0
  60. package/lib/module/index.js.map +1 -0
  61. package/lib/module/index.native.js +54 -0
  62. package/lib/module/index.native.js.map +1 -0
  63. package/lib/module/index.web.js +55 -0
  64. package/lib/module/index.web.js.map +1 -0
  65. package/lib/module/types/react-native-fs.d.js +2 -0
  66. package/lib/module/types/react-native-fs.d.js.map +1 -0
  67. package/lib/typescript/src/adapters/memoryAdapter.d.ts +23 -0
  68. package/lib/typescript/src/adapters/memoryAdapter.d.ts.map +1 -0
  69. package/lib/typescript/src/adapters/rnfsAdapter.d.ts +18 -0
  70. package/lib/typescript/src/adapters/rnfsAdapter.d.ts.map +1 -0
  71. package/lib/typescript/src/adapters/webAdapter.d.ts +30 -0
  72. package/lib/typescript/src/adapters/webAdapter.d.ts.map +1 -0
  73. package/lib/typescript/src/core/adapter.d.ts +105 -0
  74. package/lib/typescript/src/core/adapter.d.ts.map +1 -0
  75. package/lib/typescript/src/core/cacheEngine.d.ts +99 -0
  76. package/lib/typescript/src/core/cacheEngine.d.ts.map +1 -0
  77. package/lib/typescript/src/core/errors.d.ts +54 -0
  78. package/lib/typescript/src/core/errors.d.ts.map +1 -0
  79. package/lib/typescript/src/core/hash.d.ts +20 -0
  80. package/lib/typescript/src/core/hash.d.ts.map +1 -0
  81. package/lib/typescript/src/core/indexStore.d.ts +34 -0
  82. package/lib/typescript/src/core/indexStore.d.ts.map +1 -0
  83. package/lib/typescript/src/core/mutex.d.ts +49 -0
  84. package/lib/typescript/src/core/mutex.d.ts.map +1 -0
  85. package/lib/typescript/src/core/prune.d.ts +39 -0
  86. package/lib/typescript/src/core/prune.d.ts.map +1 -0
  87. package/lib/typescript/src/core/types.d.ts +109 -0
  88. package/lib/typescript/src/core/types.d.ts.map +1 -0
  89. package/lib/typescript/src/factory.d.ts +46 -0
  90. package/lib/typescript/src/factory.d.ts.map +1 -0
  91. package/lib/typescript/src/index.d.ts +20 -0
  92. package/lib/typescript/src/index.d.ts.map +1 -0
  93. package/lib/typescript/src/index.native.d.ts +37 -0
  94. package/lib/typescript/src/index.native.d.ts.map +1 -0
  95. package/lib/typescript/src/index.web.d.ts +38 -0
  96. package/lib/typescript/src/index.web.d.ts.map +1 -0
  97. package/package.json +125 -0
  98. package/src/adapters/memoryAdapter.ts +307 -0
  99. package/src/adapters/rnfsAdapter.ts +283 -0
  100. package/src/adapters/webAdapter.ts +480 -0
  101. package/src/core/adapter.ts +128 -0
  102. package/src/core/cacheEngine.ts +634 -0
  103. package/src/core/errors.ts +82 -0
  104. package/src/core/hash.ts +78 -0
  105. package/src/core/indexStore.ts +184 -0
  106. package/src/core/mutex.ts +134 -0
  107. package/src/core/prune.ts +145 -0
  108. package/src/core/types.ts +165 -0
  109. package/src/factory.ts +60 -0
  110. package/src/index.native.ts +58 -0
  111. package/src/index.ts +82 -0
  112. package/src/index.web.ts +59 -0
  113. package/src/types/react-native-fs.d.ts +75 -0
@@ -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
+ }