Skip to content

Commit 32bc5d1

Browse files
committed
feat: add cache package
1 parent 6c6c7d5 commit 32bc5d1

File tree

12 files changed

+670
-0
lines changed

12 files changed

+670
-0
lines changed

packages/cache/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# `cache` CHANGELOG
2+
3+
This is the changelog for [`cache`](https://github.com/pizzajsdev/pizza/tree/main/packages/cache). It follows
4+
[semantic versioning](https://semver.org/).
5+
6+
## 0.1.0 (2025-11-30)
7+
8+
This is the initial release of the `@pizzajsdev/cache` package.
9+
10+
See the [README](https://github.com/pizzajsdev/pizza/blob/main/packages/cache/README.md) for more details.

packages/cache/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Javier Aguilar (pizzajs.dev)
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.

packages/cache/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# cache
2+
3+
A framework-agnostic key-value cache library that works with IndexedDB, Redis, in-memory, and more
4+
5+
## Features
6+
7+
Supports these cache types:
8+
9+
- In-memory (via `lru-cache`)
10+
- IndexedDB (via `window.indexedDB` - client-side only)
11+
- Redis (via `@upstash/redis` + `superjson` - server-side only)
12+
- Null (no-op)
13+
14+
## Installation
15+
16+
```bash
17+
pnpm add @pizzajsdev/cache
18+
```
19+
20+
## Usage
21+
22+
```ts
23+
import { InMemoryKvCacheService } from '@pizzajsdev/cache/memory'
24+
const cache = new InMemoryKvCacheService()
25+
cache.set('key', 'value', 60)
26+
const value = await cache.get('key')
27+
console.log(value)
28+
```

packages/cache/package.json

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
{
2+
"name": "@pizzajsdev/cache",
3+
"version": "0.1.0",
4+
"description": "A framework-agnostic cache library that works with IndexedDB, Redis, in-memory, and more",
5+
"author": "Javier Aguilar",
6+
"license": "MIT",
7+
"repository": {
8+
"type": "git",
9+
"url": "git+https://github.com/pizzajsdev/pizza.git",
10+
"directory": "packages/cache"
11+
},
12+
"homepage": "https://github.com/pizzajsdev/pizza/tree/main/packages/cache#readme",
13+
"files": [
14+
"LICENSE",
15+
"README.md",
16+
"dist",
17+
"src",
18+
"!src/**/*.test.ts"
19+
],
20+
"type": "module",
21+
"exports": {
22+
"./indexed-db": "./src/indexed-db.ts",
23+
"./memory": "./src/memory.ts",
24+
"./redis": "./src/redis.ts",
25+
"./null": "./src/null.ts",
26+
"./": "./src/types.ts",
27+
"./package.json": "./package.json"
28+
},
29+
"publishConfig": {
30+
"exports": {
31+
"./indexed-db": {
32+
"types": "./dist/indexed-db.d.ts",
33+
"default": "./dist/indexed-db.js"
34+
},
35+
"./memory": {
36+
"types": "./dist/memory.d.ts",
37+
"default": "./dist/memory.js"
38+
},
39+
"./redis": {
40+
"types": "./dist/redis.d.ts",
41+
"default": "./dist/redis.js"
42+
},
43+
"./null": {
44+
"types": "./dist/null.d.ts",
45+
"default": "./dist/null.js"
46+
},
47+
"./": {
48+
"types": "./dist/types.d.ts",
49+
"default": "./dist/types.js"
50+
},
51+
"./package.json": "./package.json"
52+
}
53+
},
54+
"dependencies": {
55+
"superjson": "^2.2.6"
56+
},
57+
"devDependencies": {
58+
"@types/node": "catalog:",
59+
"@typescript/native-preview": "catalog:",
60+
"@upstash/redis": "^1.35.7",
61+
"lru-cache": "^11.2.2",
62+
"typescript": "catalog:"
63+
},
64+
"peerDependencies": {
65+
"@upstash/redis": "^1",
66+
"lru-cache": "^11"
67+
},
68+
"peerDependenciesMeta": {
69+
"@upstash/redis": {
70+
"optional": true
71+
},
72+
"lru-cache": {
73+
"optional": true
74+
}
75+
},
76+
"scripts": {
77+
"build": "rm -rf dist && tsgo -p tsconfig.build.json",
78+
"clean": "git clean -fdX",
79+
"prepublishOnly": "pnpm run build",
80+
"test": "node --disable-warning=ExperimentalWarning --test './src/**/*.test.ts'",
81+
"typecheck": "tsgo --noEmit"
82+
},
83+
"keywords": [
84+
"cache",
85+
"redis",
86+
"lru-cache",
87+
"indexeddb"
88+
]
89+
}

packages/cache/src/indexed-db.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import type { GenericKvCacheEntry, KeyValueCacheService } from './types.ts'
2+
3+
interface HashEntry {
4+
key: string
5+
field: string
6+
value: any
7+
}
8+
9+
export class IndexedDBKvCacheService implements KeyValueCacheService {
10+
readonly #dbName: string
11+
readonly #storeName: string
12+
readonly #hashStoreName: string
13+
14+
#db: IDBDatabase | null = null
15+
#dbInitPromise: Promise<void> | null = null
16+
17+
constructor(dbName: string, storeName: string, hashStoreName: string) {
18+
this.#dbName = dbName
19+
this.#storeName = storeName
20+
this.#hashStoreName = hashStoreName
21+
this.#db = null
22+
this.#dbInitPromise = null
23+
}
24+
25+
async #initDb(): Promise<void> {
26+
if (this.#db) return
27+
28+
if (this.#dbInitPromise) {
29+
return this.#dbInitPromise
30+
}
31+
32+
this.#dbInitPromise = new Promise((resolve, reject) => {
33+
let request = indexedDB.open(this.#dbName, 1)
34+
35+
request.onerror = () => reject(request.error)
36+
37+
request.onupgradeneeded = (event) => {
38+
let db = (event.target as IDBOpenDBRequest).result
39+
40+
// Create store for regular key-value pairs
41+
if (!db.objectStoreNames.contains(this.#storeName)) {
42+
db.createObjectStore(this.#storeName, { keyPath: 'key' })
43+
}
44+
45+
// Create store for hash fields
46+
if (!db.objectStoreNames.contains(this.#hashStoreName)) {
47+
let hashStore = db.createObjectStore(this.#hashStoreName, { keyPath: ['key', 'field'] })
48+
hashStore.createIndex('key_index', 'key')
49+
}
50+
}
51+
52+
request.onsuccess = () => {
53+
this.#db = request.result
54+
resolve()
55+
}
56+
})
57+
58+
return this.#dbInitPromise
59+
}
60+
61+
#getStore(storeName: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
62+
if (!this.#db) throw new Error('Database not initialized')
63+
let transaction = this.#db.transaction(storeName, mode)
64+
return transaction.objectStore(storeName)
65+
}
66+
67+
async has(key: string): Promise<boolean> {
68+
await this.#initDb()
69+
return new Promise((resolve, reject) => {
70+
let store = this.#getStore(this.#storeName)
71+
let request = store.get(key)
72+
73+
request.onerror = () => reject(request.error)
74+
request.onsuccess = () => {
75+
let entry = request.result as GenericKvCacheEntry | undefined
76+
if (!entry) {
77+
resolve(false)
78+
return
79+
}
80+
81+
if (entry.expiresAt && entry.expiresAt < Date.now()) {
82+
resolve(false)
83+
return
84+
}
85+
86+
resolve(true)
87+
}
88+
})
89+
}
90+
91+
async get<T = any>(key: string): Promise<T | null> {
92+
await this.#initDb()
93+
return new Promise((resolve, reject) => {
94+
let store = this.#getStore(this.#storeName)
95+
let request = store.get(key)
96+
97+
request.onerror = () => reject(request.error)
98+
request.onsuccess = () => {
99+
let entry = request.result as GenericKvCacheEntry | undefined
100+
if (!entry) {
101+
resolve(null)
102+
return
103+
}
104+
105+
if (entry.expiresAt && entry.expiresAt < Date.now()) {
106+
resolve(null)
107+
return
108+
}
109+
110+
resolve(entry.value)
111+
}
112+
})
113+
}
114+
115+
async hashGet<T = string>(key: string, field: string): Promise<T | null> {
116+
await this.#initDb()
117+
return new Promise((resolve, reject) => {
118+
let store = this.#getStore(this.#hashStoreName)
119+
let request = store.get([key, field])
120+
121+
request.onerror = () => reject(request.error)
122+
request.onsuccess = () => {
123+
let entry = request.result as HashEntry | undefined
124+
resolve(entry?.value ?? null)
125+
}
126+
})
127+
}
128+
129+
async getObject<T = any>(key: string): Promise<T | null> {
130+
let value = await this.get<T>(key)
131+
if (!value) return null
132+
try {
133+
return value
134+
} catch {
135+
return null
136+
}
137+
}
138+
139+
async set<T = any>(key: string, value: T, expirationSeconds: number): Promise<void> {
140+
await this.#initDb()
141+
return new Promise((resolve, reject) => {
142+
let store = this.#getStore(this.#storeName, 'readwrite')
143+
let expiresAt = expirationSeconds > 0 ? Date.now() + expirationSeconds * 1000 : null
144+
let request = store.put({ key, value, expiresAt } satisfies GenericKvCacheEntry & { key: string })
145+
146+
request.onerror = () => reject(request.error)
147+
request.onsuccess = () => resolve()
148+
})
149+
}
150+
151+
async setObject<T = any>(key: string, value: T, expirationSeconds: number): Promise<void> {
152+
return this.set(key, value, expirationSeconds)
153+
}
154+
155+
async hashSet(key: string, field: string, value: string | undefined): Promise<void> {
156+
await this.#initDb()
157+
return new Promise((resolve, reject) => {
158+
let store = this.#getStore(this.#hashStoreName, 'readwrite')
159+
160+
if (value === undefined) {
161+
let request = store.delete([key, field])
162+
request.onerror = () => reject(request.error)
163+
request.onsuccess = () => resolve()
164+
} else {
165+
let request = store.put({ key, field, value } satisfies HashEntry)
166+
request.onerror = () => reject(request.error)
167+
request.onsuccess = () => resolve()
168+
}
169+
})
170+
}
171+
172+
async delete(...keys: string[]): Promise<number> {
173+
await this.#initDb()
174+
return new Promise((resolve, reject) => {
175+
let store = this.#getStore(this.#storeName, 'readwrite')
176+
let deletedCount = 0
177+
let completedCount = 0
178+
179+
let checkCompletion = () => {
180+
if (completedCount === keys.length) {
181+
resolve(deletedCount)
182+
}
183+
}
184+
185+
keys.forEach((key) => {
186+
let request = store.delete(key)
187+
request.onerror = () => reject(request.error)
188+
request.onsuccess = () => {
189+
deletedCount++
190+
completedCount++
191+
checkCompletion()
192+
}
193+
})
194+
195+
// Handle empty keys array
196+
if (keys.length === 0) {
197+
resolve(0)
198+
}
199+
})
200+
}
201+
202+
async keys(): Promise<string[]> {
203+
await this.#initDb()
204+
return new Promise((resolve, reject) => {
205+
let store = this.#getStore(this.#storeName)
206+
let request = store.getAll()
207+
request.onerror = () => reject(request.error)
208+
request.onsuccess = () => resolve(request.result.map((entry) => entry.key))
209+
})
210+
}
211+
212+
async size(): Promise<number> {
213+
await this.#initDb()
214+
return new Promise((resolve, reject) => {
215+
let store = this.#getStore(this.#storeName)
216+
let request = store.count()
217+
request.onerror = () => reject(request.error)
218+
request.onsuccess = () => resolve(request.result)
219+
})
220+
}
221+
}

0 commit comments

Comments
 (0)