@open-mercato/cache 0.6.4-develop.4270.1.a614eb18e6 → 0.6.4-develop.4299.1.af24e08431
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/AGENTS.md +2 -2
- package/ENV.md +2 -2
- package/README.md +2 -0
- package/dist/strategies/sqlite.js +7 -1
- package/dist/strategies/sqlite.js.map +2 -2
- package/package.json +1 -1
- package/src/__tests__/sqlite.strategy.test.ts +163 -0
- package/src/strategies/sqlite.ts +10 -2
package/AGENTS.md
CHANGED
|
@@ -7,8 +7,8 @@ Use `@open-mercato/cache` for all caching needs. MUST NOT use raw Redis, SQLite,
|
|
|
7
7
|
| Strategy | When to use | Configuration |
|
|
8
8
|
|----------|-------------|---------------|
|
|
9
9
|
| Memory | Use for development and single-process apps | Default (no config needed) |
|
|
10
|
-
| SQLite | Use for single-server production deployments | `CACHE_STRATEGY=sqlite` |
|
|
11
|
-
| Redis | Use for multi-server production with
|
|
10
|
+
| SQLite | Use for single-server production deployments; local persistent convenience cache, tuned with WAL/`synchronous=NORMAL` | `CACHE_STRATEGY=sqlite` |
|
|
11
|
+
| Redis | Use for multi-server production or latency-sensitive request paths with frequent cache writes | `CACHE_STRATEGY=redis` |
|
|
12
12
|
|
|
13
13
|
## Always
|
|
14
14
|
|
package/ENV.md
CHANGED
|
@@ -88,7 +88,7 @@ CACHE_TTL=300000
|
|
|
88
88
|
|
|
89
89
|
### Production (Redis)
|
|
90
90
|
|
|
91
|
-
Shared across instances, persistent, requires Redis server.
|
|
91
|
+
Shared across instances, persistent, requires Redis server. Recommended for multi-server deployments and latency-sensitive request paths that perform several cache writes per request.
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
94
|
CACHE_STRATEGY=redis
|
|
@@ -98,7 +98,7 @@ CACHE_TTL=600000
|
|
|
98
98
|
|
|
99
99
|
### Production (SQLite)
|
|
100
100
|
|
|
101
|
-
Persistent, file-based, good for single instance deployments.
|
|
101
|
+
Persistent, file-based, good for single instance deployments. The cache database is tuned with WAL and `synchronous=NORMAL`; cache entries can be regenerated if the process crashes before SQLite fully persists a recent write.
|
|
102
102
|
|
|
103
103
|
```bash
|
|
104
104
|
CACHE_STRATEGY=sqlite
|
package/README.md
CHANGED
|
@@ -57,6 +57,8 @@ await cache.close() // dispose connections on shutdown
|
|
|
57
57
|
| `sqlite` | ✅ local | single node | `better-sqlite3` | Edge workers, self-hosted admin tooling |
|
|
58
58
|
| `jsonfile` | ✅ local | single node | – | Debugging snapshots, very small deployments |
|
|
59
59
|
|
|
60
|
+
The SQLite strategy opens its database in WAL mode with `synchronous=NORMAL`, which is appropriate for cache data that can be regenerated after a crash. Use Redis for multi-process deployments or latency-sensitive request paths that perform many cache writes per request.
|
|
61
|
+
|
|
60
62
|
Switch the strategy via:
|
|
61
63
|
|
|
62
64
|
- `CACHE_STRATEGY` env var (`memory` default), or
|
|
@@ -12,13 +12,19 @@ function createSqliteStrategy(dbPath, options) {
|
|
|
12
12
|
async function getDb() {
|
|
13
13
|
if (db) return db;
|
|
14
14
|
try {
|
|
15
|
-
const imported = sqliteRequire("better-sqlite3");
|
|
15
|
+
const imported = options?.databaseConstructor ?? sqliteRequire("better-sqlite3");
|
|
16
16
|
const Database = typeof imported === "function" ? imported : imported.default;
|
|
17
17
|
const dir = path.dirname(filePath);
|
|
18
18
|
if (!fs.existsSync(dir)) {
|
|
19
19
|
fs.mkdirSync(dir, { recursive: true });
|
|
20
20
|
}
|
|
21
21
|
db = new Database(filePath);
|
|
22
|
+
db.exec(`
|
|
23
|
+
PRAGMA journal_mode = WAL;
|
|
24
|
+
PRAGMA synchronous = NORMAL;
|
|
25
|
+
PRAGMA busy_timeout = 5000;
|
|
26
|
+
PRAGMA foreign_keys = ON;
|
|
27
|
+
`);
|
|
22
28
|
db.exec(`
|
|
23
29
|
CREATE TABLE IF NOT EXISTS cache_entries (
|
|
24
30
|
key TEXT PRIMARY KEY,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/strategies/sqlite.ts"],
|
|
4
|
-
"sourcesContent": ["import type { CacheStrategy, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport fs from 'node:fs'\nimport { createRequire } from 'node:module'\nimport path from 'node:path'\nimport { CacheDependencyUnavailableError } from '../errors'\nimport { DEFAULT_SQLITE_CACHE_PATH } from '../defaults'\nimport { matchCacheKeyPattern } from '../patterns'\n\ntype SqliteStatement<TResult = unknown> = {\n get(...args: unknown[]): TResult | undefined\n all(...args: unknown[]): TResult[]\n run(...args: unknown[]): { changes: number }\n}\n\ntype SqliteTransaction<TResult = unknown> = () => TResult\n\ntype SqliteDatabase = {\n prepare<TResult = unknown>(sql: string): SqliteStatement<TResult>\n exec(sql: string): unknown\n transaction<TResult>(fn: () => TResult): SqliteTransaction<TResult>\n close(): void\n}\n\ntype SqliteConstructor = new (file: string) => SqliteDatabase\ntype SqliteModule = SqliteConstructor | { default: SqliteConstructor }\n\nconst sqliteRequire = createRequire(path.join(process.cwd(), 'package.json'))\n\n/**\n * SQLite cache strategy with tag support\n * Persistent across process restarts, stored in a SQLite database file\n * \n * Uses two tables:\n * - cache_entries: stores cache data\n * - cache_tags: stores tag associations (many-to-many)\n */\nexport function createSqliteStrategy(dbPath?: string, options?:
|
|
5
|
-
"mappings": "AACA,OAAO,QAAQ;AACf,SAAS,qBAAqB;AAC9B,OAAO,UAAU;AACjB,SAAS,uCAAuC;AAChD,SAAS,iCAAiC;AAC1C,SAAS,4BAA4B;
|
|
4
|
+
"sourcesContent": ["import type { CacheStrategy, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport fs from 'node:fs'\nimport { createRequire } from 'node:module'\nimport path from 'node:path'\nimport { CacheDependencyUnavailableError } from '../errors'\nimport { DEFAULT_SQLITE_CACHE_PATH } from '../defaults'\nimport { matchCacheKeyPattern } from '../patterns'\n\ntype SqliteStatement<TResult = unknown> = {\n get(...args: unknown[]): TResult | undefined\n all(...args: unknown[]): TResult[]\n run(...args: unknown[]): { changes: number }\n}\n\ntype SqliteTransaction<TResult = unknown> = () => TResult\n\ntype SqliteDatabase = {\n prepare<TResult = unknown>(sql: string): SqliteStatement<TResult>\n exec(sql: string): unknown\n transaction<TResult>(fn: () => TResult): SqliteTransaction<TResult>\n close(): void\n}\n\ntype SqliteConstructor = new (file: string) => SqliteDatabase\ntype SqliteModule = SqliteConstructor | { default: SqliteConstructor }\ntype SqliteStrategyOptions = { defaultTtl?: number; databaseConstructor?: SqliteConstructor }\n\nconst sqliteRequire = createRequire(path.join(process.cwd(), 'package.json'))\n\n/**\n * SQLite cache strategy with tag support\n * Persistent across process restarts, stored in a SQLite database file\n * \n * Uses two tables:\n * - cache_entries: stores cache data\n * - cache_tags: stores tag associations (many-to-many)\n */\nexport function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOptions): CacheStrategy {\n let db: SqliteDatabase | null = null\n const defaultTtl = options?.defaultTtl\n const filePath = dbPath || process.env.CACHE_SQLITE_PATH || DEFAULT_SQLITE_CACHE_PATH\n\n async function getDb(): Promise<SqliteDatabase> {\n if (db) return db\n\n try {\n const imported = (options?.databaseConstructor ?? sqliteRequire('better-sqlite3')) as SqliteModule\n const Database = typeof imported === 'function' ? imported : imported.default\n \n // Ensure directory exists\n const dir = path.dirname(filePath)\n if (!fs.existsSync(dir)) {\n fs.mkdirSync(dir, { recursive: true })\n }\n\n db = new Database(filePath)\n\n db.exec(`\n PRAGMA journal_mode = WAL;\n PRAGMA synchronous = NORMAL;\n PRAGMA busy_timeout = 5000;\n PRAGMA foreign_keys = ON;\n `)\n\n // Create tables\n db.exec(`\n CREATE TABLE IF NOT EXISTS cache_entries (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n expires_at INTEGER,\n created_at INTEGER NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS cache_tags (\n key TEXT NOT NULL,\n tag TEXT NOT NULL,\n PRIMARY KEY (key, tag),\n FOREIGN KEY (key) REFERENCES cache_entries(key) ON DELETE CASCADE\n );\n\n CREATE INDEX IF NOT EXISTS idx_cache_tags_tag ON cache_tags(tag);\n CREATE INDEX IF NOT EXISTS idx_cache_entries_expires_at ON cache_entries(expires_at);\n `)\n\n return db\n } catch (error) {\n throw new CacheDependencyUnavailableError('sqlite', 'better-sqlite3', error)\n }\n }\n\n function isExpired(expiresAt: number | null): boolean {\n if (expiresAt === null) return false\n return Date.now() > expiresAt\n }\n\n type EntryRow = { value: string; expires_at: number | null }\n type ExpiresRow = { expires_at: number | null }\n type CountRow = { count: number }\n type KeyRow = { key: string }\n\n const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {\n const database = await getDb()\n \n const stmt = database.prepare('SELECT value, expires_at FROM cache_entries WHERE key = ?')\n const row = stmt.get(key) as EntryRow | undefined\n\n if (!row) return null\n\n try {\n const value = JSON.parse(row.value) as CacheValue\n const expiresAt = row.expires_at\n\n if (isExpired(expiresAt)) {\n if (options?.returnExpired) {\n return value\n }\n // Clean up expired entry\n await deleteKey(key)\n return null\n }\n\n return value\n } catch {\n // Invalid JSON, remove it\n await deleteKey(key)\n return null\n }\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {\n const database = await getDb()\n const ttl = options?.ttl ?? defaultTtl\n const tags = options?.tags || []\n const expiresAt = ttl ? Date.now() + ttl : null\n const createdAt = Date.now()\n\n const serialized = JSON.stringify(value)\n\n database.transaction(() => {\n // Delete old tags\n database.prepare('DELETE FROM cache_tags WHERE key = ?').run(key)\n\n // Insert or replace cache entry\n database.prepare(`\n INSERT OR REPLACE INTO cache_entries (key, value, expires_at, created_at)\n VALUES (?, ?, ?, ?)\n `).run(key, serialized, expiresAt, createdAt)\n\n // Insert new tags\n if (tags.length > 0) {\n const insertTag = database.prepare('INSERT INTO cache_tags (key, tag) VALUES (?, ?)')\n for (const tag of tags) {\n insertTag.run(key, tag)\n }\n }\n })()\n }\n\n const has = async (key: string): Promise<boolean> => {\n const database = await getDb()\n \n const stmt = database.prepare('SELECT expires_at FROM cache_entries WHERE key = ?')\n const row = stmt.get(key) as ExpiresRow | undefined\n\n if (!row) return false\n\n if (isExpired(row.expires_at)) {\n await deleteKey(key)\n return false\n }\n\n return true\n }\n\n const deleteKey = async (key: string): Promise<boolean> => {\n const database = await getDb()\n \n const result = database.transaction(() => {\n database.prepare('DELETE FROM cache_tags WHERE key = ?').run(key)\n const info = database.prepare('DELETE FROM cache_entries WHERE key = ?').run(key)\n return info.changes > 0\n })()\n\n return result\n }\n\n const deleteByTags = async (tags: string[]): Promise<number> => {\n const database = await getDb()\n \n // Get all unique keys that have any of the specified tags\n const placeholders = tags.map(() => '?').join(',')\n const stmt = database.prepare(`\n SELECT DISTINCT key FROM cache_tags WHERE tag IN (${placeholders})\n `)\n const rows = stmt.all(...tags) as KeyRow[]\n\n let deleted = 0\n for (const row of rows) {\n const success = await deleteKey(row.key)\n if (success) deleted++\n }\n\n return deleted\n }\n\n const clear = async (): Promise<number> => {\n const database = await getDb()\n \n const result = database.transaction(() => {\n const countStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries')\n const count = (countStmt.get() as CountRow).count\n\n database.prepare('DELETE FROM cache_tags').run()\n database.prepare('DELETE FROM cache_entries').run()\n\n return count\n })()\n\n return result\n }\n\n const keys = async (pattern?: string): Promise<string[]> => {\n const database = await getDb()\n \n const stmt = database.prepare('SELECT key FROM cache_entries')\n const rows = stmt.all() as KeyRow[]\n \n const allKeys = rows.map((row) => row.key)\n \n if (!pattern) return allKeys\n \n return allKeys.filter((key: string) => matchCacheKeyPattern(key, pattern))\n }\n\n const stats = async (): Promise<{ size: number; expired: number }> => {\n const database = await getDb()\n \n const sizeStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries')\n const size = (sizeStmt.get() as CountRow).count\n\n const now = Date.now()\n const expiredStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?')\n const expired = (expiredStmt.get(now) as CountRow).count\n\n return { size, expired }\n }\n\n const cleanup = async (): Promise<number> => {\n const database = await getDb()\n const now = Date.now()\n \n const result = database.transaction(() => {\n // Get keys to delete\n const stmt = database.prepare('SELECT key FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?')\n const rows = stmt.all(now) as KeyRow[]\n\n // Delete tags for expired keys\n for (const row of rows) {\n database.prepare('DELETE FROM cache_tags WHERE key = ?').run(row.key)\n }\n\n // Delete expired entries\n const info = database.prepare('DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?').run(now)\n return info.changes\n })()\n\n return result\n }\n\n const close = async (): Promise<void> => {\n if (db) {\n db.close()\n db = null\n }\n }\n\n return {\n get,\n set,\n has,\n delete: deleteKey,\n deleteByTags,\n clear,\n keys,\n stats,\n cleanup,\n close,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["options"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { createSqliteStrategy } from '../strategies/sqlite'
|
|
5
|
+
|
|
6
|
+
type CacheEntry = { value: string; expires_at: number | null; created_at: number }
|
|
7
|
+
|
|
8
|
+
class MockSqliteDatabase {
|
|
9
|
+
static instances: MockSqliteDatabase[] = []
|
|
10
|
+
|
|
11
|
+
execCalls: string[] = []
|
|
12
|
+
entries = new Map<string, CacheEntry>()
|
|
13
|
+
tags = new Map<string, Set<string>>()
|
|
14
|
+
closed = false
|
|
15
|
+
|
|
16
|
+
constructor(public readonly file: string) {
|
|
17
|
+
MockSqliteDatabase.instances.push(this)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
exec(sql: string): void {
|
|
21
|
+
this.execCalls.push(sql)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
transaction<TResult>(fn: () => TResult): () => TResult {
|
|
25
|
+
return fn
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
close(): void {
|
|
29
|
+
this.closed = true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
prepare<TResult = unknown>(sql: string): {
|
|
33
|
+
get(...args: unknown[]): TResult | undefined
|
|
34
|
+
all(...args: unknown[]): TResult[]
|
|
35
|
+
run(...args: unknown[]): { changes: number }
|
|
36
|
+
} {
|
|
37
|
+
const normalized = sql.replace(/\s+/g, ' ').trim()
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
get: (...args: unknown[]) => this.get(normalized, args) as TResult | undefined,
|
|
41
|
+
all: (...args: unknown[]) => this.all(normalized, args) as TResult[],
|
|
42
|
+
run: (...args: unknown[]) => this.run(normalized, args),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private get(sql: string, args: unknown[]) {
|
|
47
|
+
if (sql === 'SELECT value, expires_at FROM cache_entries WHERE key = ?') {
|
|
48
|
+
const entry = this.entries.get(String(args[0]))
|
|
49
|
+
return entry ? { value: entry.value, expires_at: entry.expires_at } : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (sql === 'SELECT expires_at FROM cache_entries WHERE key = ?') {
|
|
53
|
+
const entry = this.entries.get(String(args[0]))
|
|
54
|
+
return entry ? { expires_at: entry.expires_at } : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (sql === 'SELECT COUNT(*) as count FROM cache_entries') {
|
|
58
|
+
return { count: this.entries.size }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error(`Unhandled sqlite get statement: ${sql}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private all(sql: string, args: unknown[]) {
|
|
65
|
+
if (sql.startsWith('SELECT DISTINCT key FROM cache_tags WHERE tag IN')) {
|
|
66
|
+
const matching = new Set<string>()
|
|
67
|
+
|
|
68
|
+
for (const [key, tags] of this.tags) {
|
|
69
|
+
if (args.some((tag) => tags.has(String(tag)))) {
|
|
70
|
+
matching.add(key)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return Array.from(matching).map((key) => ({ key }))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (sql === 'SELECT key FROM cache_entries') {
|
|
78
|
+
return Array.from(this.entries.keys()).map((key) => ({ key }))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw new Error(`Unhandled sqlite all statement: ${sql}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private run(sql: string, args: unknown[]) {
|
|
85
|
+
if (sql === 'DELETE FROM cache_tags WHERE key = ?') {
|
|
86
|
+
this.tags.delete(String(args[0]))
|
|
87
|
+
return { changes: 1 }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (sql.startsWith('INSERT OR REPLACE INTO cache_entries')) {
|
|
91
|
+
const [key, value, expiresAt, createdAt] = args
|
|
92
|
+
this.entries.set(String(key), {
|
|
93
|
+
value: String(value),
|
|
94
|
+
expires_at: expiresAt === null ? null : Number(expiresAt),
|
|
95
|
+
created_at: Number(createdAt),
|
|
96
|
+
})
|
|
97
|
+
return { changes: 1 }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (sql === 'INSERT INTO cache_tags (key, tag) VALUES (?, ?)') {
|
|
101
|
+
const key = String(args[0])
|
|
102
|
+
const tag = String(args[1])
|
|
103
|
+
const tags = this.tags.get(key) ?? new Set<string>()
|
|
104
|
+
tags.add(tag)
|
|
105
|
+
this.tags.set(key, tags)
|
|
106
|
+
return { changes: 1 }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (sql === 'DELETE FROM cache_entries WHERE key = ?') {
|
|
110
|
+
const key = String(args[0])
|
|
111
|
+
const existed = this.entries.delete(key)
|
|
112
|
+
this.tags.delete(key)
|
|
113
|
+
return { changes: existed ? 1 : 0 }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
throw new Error(`Unhandled sqlite run statement: ${sql}`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe('SQLite Cache Strategy', () => {
|
|
121
|
+
let tempDir: string
|
|
122
|
+
let dbPath: string
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
MockSqliteDatabase.instances = []
|
|
126
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-mercato-cache-'))
|
|
127
|
+
dbPath = path.join(tempDir, 'cache.db')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
afterEach(() => {
|
|
131
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('configures cache-appropriate SQLite pragmas on open', async () => {
|
|
135
|
+
const cache = createSqliteStrategy(dbPath, { databaseConstructor: MockSqliteDatabase })
|
|
136
|
+
|
|
137
|
+
await cache.set('list:page:1', { rows: [{ id: '1', name: 'Ada' }] }, { tags: ['customers', 'org:1'] })
|
|
138
|
+
|
|
139
|
+
const database = MockSqliteDatabase.instances[0]
|
|
140
|
+
expect(database.execCalls[0]).toContain('PRAGMA journal_mode = WAL;')
|
|
141
|
+
expect(database.execCalls[0]).toContain('PRAGMA synchronous = NORMAL;')
|
|
142
|
+
expect(database.execCalls[0]).toContain('PRAGMA busy_timeout = 5000;')
|
|
143
|
+
expect(database.execCalls[0]).toContain('PRAGMA foreign_keys = ON;')
|
|
144
|
+
|
|
145
|
+
await cache.close?.()
|
|
146
|
+
expect(database.closed).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('preserves tag replacement and invalidation behavior', async () => {
|
|
150
|
+
const cache = createSqliteStrategy(dbPath, { databaseConstructor: MockSqliteDatabase })
|
|
151
|
+
|
|
152
|
+
await cache.set('user:1', { name: 'Ada' }, { tags: ['users', 'org:1'] })
|
|
153
|
+
await cache.set('user:1', { name: 'Ada Lovelace' }, { tags: ['users', 'org:2'] })
|
|
154
|
+
|
|
155
|
+
expect(await cache.deleteByTags(['org:1'])).toBe(0)
|
|
156
|
+
expect(await cache.get('user:1')).toEqual({ name: 'Ada Lovelace' })
|
|
157
|
+
|
|
158
|
+
expect(await cache.deleteByTags(['org:2'])).toBe(1)
|
|
159
|
+
expect(await cache.get('user:1')).toBeNull()
|
|
160
|
+
|
|
161
|
+
await cache.close?.()
|
|
162
|
+
})
|
|
163
|
+
})
|
package/src/strategies/sqlite.ts
CHANGED
|
@@ -23,6 +23,7 @@ type SqliteDatabase = {
|
|
|
23
23
|
|
|
24
24
|
type SqliteConstructor = new (file: string) => SqliteDatabase
|
|
25
25
|
type SqliteModule = SqliteConstructor | { default: SqliteConstructor }
|
|
26
|
+
type SqliteStrategyOptions = { defaultTtl?: number; databaseConstructor?: SqliteConstructor }
|
|
26
27
|
|
|
27
28
|
const sqliteRequire = createRequire(path.join(process.cwd(), 'package.json'))
|
|
28
29
|
|
|
@@ -34,7 +35,7 @@ const sqliteRequire = createRequire(path.join(process.cwd(), 'package.json'))
|
|
|
34
35
|
* - cache_entries: stores cache data
|
|
35
36
|
* - cache_tags: stores tag associations (many-to-many)
|
|
36
37
|
*/
|
|
37
|
-
export function createSqliteStrategy(dbPath?: string, options?:
|
|
38
|
+
export function createSqliteStrategy(dbPath?: string, options?: SqliteStrategyOptions): CacheStrategy {
|
|
38
39
|
let db: SqliteDatabase | null = null
|
|
39
40
|
const defaultTtl = options?.defaultTtl
|
|
40
41
|
const filePath = dbPath || process.env.CACHE_SQLITE_PATH || DEFAULT_SQLITE_CACHE_PATH
|
|
@@ -43,7 +44,7 @@ export function createSqliteStrategy(dbPath?: string, options?: { defaultTtl?: n
|
|
|
43
44
|
if (db) return db
|
|
44
45
|
|
|
45
46
|
try {
|
|
46
|
-
const imported = sqliteRequire('better-sqlite3') as SqliteModule
|
|
47
|
+
const imported = (options?.databaseConstructor ?? sqliteRequire('better-sqlite3')) as SqliteModule
|
|
47
48
|
const Database = typeof imported === 'function' ? imported : imported.default
|
|
48
49
|
|
|
49
50
|
// Ensure directory exists
|
|
@@ -54,6 +55,13 @@ export function createSqliteStrategy(dbPath?: string, options?: { defaultTtl?: n
|
|
|
54
55
|
|
|
55
56
|
db = new Database(filePath)
|
|
56
57
|
|
|
58
|
+
db.exec(`
|
|
59
|
+
PRAGMA journal_mode = WAL;
|
|
60
|
+
PRAGMA synchronous = NORMAL;
|
|
61
|
+
PRAGMA busy_timeout = 5000;
|
|
62
|
+
PRAGMA foreign_keys = ON;
|
|
63
|
+
`)
|
|
64
|
+
|
|
57
65
|
// Create tables
|
|
58
66
|
db.exec(`
|
|
59
67
|
CREATE TABLE IF NOT EXISTS cache_entries (
|