@mantiq/core 0.0.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 (82) hide show
  1. package/README.md +19 -0
  2. package/package.json +65 -0
  3. package/src/application/Application.ts +241 -0
  4. package/src/cache/CacheManager.ts +180 -0
  5. package/src/cache/FileCacheStore.ts +113 -0
  6. package/src/cache/MemcachedCacheStore.ts +115 -0
  7. package/src/cache/MemoryCacheStore.ts +62 -0
  8. package/src/cache/NullCacheStore.ts +39 -0
  9. package/src/cache/RedisCacheStore.ts +125 -0
  10. package/src/cache/events.ts +52 -0
  11. package/src/config/ConfigRepository.ts +115 -0
  12. package/src/config/env.ts +26 -0
  13. package/src/container/Container.ts +198 -0
  14. package/src/container/ContextualBindingBuilder.ts +21 -0
  15. package/src/contracts/Cache.ts +49 -0
  16. package/src/contracts/Config.ts +24 -0
  17. package/src/contracts/Container.ts +68 -0
  18. package/src/contracts/DriverManager.ts +16 -0
  19. package/src/contracts/Encrypter.ts +32 -0
  20. package/src/contracts/EventDispatcher.ts +32 -0
  21. package/src/contracts/ExceptionHandler.ts +20 -0
  22. package/src/contracts/Hasher.ts +19 -0
  23. package/src/contracts/Middleware.ts +23 -0
  24. package/src/contracts/Request.ts +54 -0
  25. package/src/contracts/Response.ts +19 -0
  26. package/src/contracts/Router.ts +62 -0
  27. package/src/contracts/ServiceProvider.ts +31 -0
  28. package/src/contracts/Session.ts +47 -0
  29. package/src/encryption/Encrypter.ts +197 -0
  30. package/src/encryption/errors.ts +30 -0
  31. package/src/errors/ConfigKeyNotFoundError.ts +7 -0
  32. package/src/errors/ContainerResolutionError.ts +13 -0
  33. package/src/errors/ForbiddenError.ts +7 -0
  34. package/src/errors/HttpError.ts +16 -0
  35. package/src/errors/MantiqError.ts +16 -0
  36. package/src/errors/NotFoundError.ts +7 -0
  37. package/src/errors/TokenMismatchError.ts +10 -0
  38. package/src/errors/TooManyRequestsError.ts +10 -0
  39. package/src/errors/UnauthorizedError.ts +7 -0
  40. package/src/errors/ValidationError.ts +10 -0
  41. package/src/exceptions/DevErrorPage.ts +564 -0
  42. package/src/exceptions/Handler.ts +118 -0
  43. package/src/hashing/Argon2Hasher.ts +46 -0
  44. package/src/hashing/BcryptHasher.ts +36 -0
  45. package/src/hashing/HashManager.ts +80 -0
  46. package/src/helpers/abort.ts +46 -0
  47. package/src/helpers/app.ts +17 -0
  48. package/src/helpers/cache.ts +12 -0
  49. package/src/helpers/config.ts +15 -0
  50. package/src/helpers/encrypt.ts +22 -0
  51. package/src/helpers/env.ts +1 -0
  52. package/src/helpers/hash.ts +20 -0
  53. package/src/helpers/response.ts +69 -0
  54. package/src/helpers/route.ts +24 -0
  55. package/src/helpers/session.ts +11 -0
  56. package/src/http/Cookie.ts +26 -0
  57. package/src/http/Kernel.ts +252 -0
  58. package/src/http/Request.ts +249 -0
  59. package/src/http/Response.ts +112 -0
  60. package/src/http/UploadedFile.ts +56 -0
  61. package/src/index.ts +97 -0
  62. package/src/macroable/Macroable.ts +174 -0
  63. package/src/middleware/Cors.ts +91 -0
  64. package/src/middleware/EncryptCookies.ts +101 -0
  65. package/src/middleware/Pipeline.ts +66 -0
  66. package/src/middleware/StartSession.ts +90 -0
  67. package/src/middleware/TrimStrings.ts +32 -0
  68. package/src/middleware/VerifyCsrfToken.ts +130 -0
  69. package/src/providers/CoreServiceProvider.ts +97 -0
  70. package/src/routing/ResourceRegistrar.ts +64 -0
  71. package/src/routing/Route.ts +40 -0
  72. package/src/routing/RouteCollection.ts +50 -0
  73. package/src/routing/RouteMatcher.ts +92 -0
  74. package/src/routing/Router.ts +280 -0
  75. package/src/routing/events.ts +19 -0
  76. package/src/session/SessionManager.ts +75 -0
  77. package/src/session/Store.ts +192 -0
  78. package/src/session/handlers/CookieSessionHandler.ts +42 -0
  79. package/src/session/handlers/FileSessionHandler.ts +79 -0
  80. package/src/session/handlers/MemorySessionHandler.ts +35 -0
  81. package/src/websocket/WebSocketContext.ts +20 -0
  82. package/src/websocket/WebSocketKernel.ts +60 -0
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @mantiq/core
2
+
3
+ The foundation of MantiqJS — service container, router, middleware, HTTP kernel, config, encryption, hashing, caching, and sessions.
4
+
5
+ Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bun add @mantiq/core
11
+ ```
12
+
13
+ ## Documentation
14
+
15
+ See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
16
+
17
+ ## License
18
+
19
+ MIT
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@mantiq/core",
3
+ "version": "0.0.1",
4
+ "description": "Service container, router, middleware, HTTP kernel, config, and exception handler",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Abdullah Khan",
8
+ "homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/core",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/abdullahkhan/mantiq.git",
12
+ "directory": "packages/core"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/abdullahkhan/mantiq/issues"
16
+ },
17
+ "keywords": [
18
+ "mantiq",
19
+ "mantiqjs",
20
+ "bun",
21
+ "typescript",
22
+ "framework",
23
+ "core"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.1.0"
27
+ },
28
+ "main": "./src/index.ts",
29
+ "types": "./src/index.ts",
30
+ "exports": {
31
+ ".": {
32
+ "bun": "./src/index.ts",
33
+ "default": "./src/index.ts"
34
+ }
35
+ },
36
+ "files": [
37
+ "src/",
38
+ "package.json",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "scripts": {
43
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun",
44
+ "test": "bun test",
45
+ "typecheck": "tsc --noEmit",
46
+ "clean": "rm -rf dist"
47
+ },
48
+ "devDependencies": {
49
+ "bun-types": "latest",
50
+ "typescript": "^5.7.0"
51
+ },
52
+ "peerDependencies": {
53
+ "bun": ">=1.1.0",
54
+ "ioredis": ">=5.0.0",
55
+ "memjs": ">=1.3.0"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "ioredis": {
59
+ "optional": true
60
+ },
61
+ "memjs": {
62
+ "optional": true
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,241 @@
1
+ import { ContainerImpl } from '../container/Container.ts'
2
+ import { ConfigRepository } from '../config/ConfigRepository.ts'
3
+ import { ContainerResolutionError } from '../errors/ContainerResolutionError.ts'
4
+ import type { ServiceProvider } from '../contracts/ServiceProvider.ts'
5
+ import type { Bindable, Constructor } from '../contracts/Container.ts'
6
+
7
+ /**
8
+ * The Application is the heart of MantiqJS.
9
+ *
10
+ * It extends the service container and owns:
11
+ * - Config loading (always happens before any provider runs)
12
+ * - Provider registration + boot lifecycle
13
+ * - Deferred provider resolution
14
+ * - The global singleton accessible via app()
15
+ *
16
+ * Boot sequence:
17
+ * const app = await Application.create('config/')
18
+ * await app.registerProviders([...])
19
+ * await app.bootProviders()
20
+ * await app.make(HttpKernel).start()
21
+ */
22
+ export class Application extends ContainerImpl {
23
+ /** The global singleton instance — set once at bootstrap. */
24
+ private static _instance: Application | null = null
25
+
26
+ private providers: ServiceProvider[] = []
27
+ private deferredProviders = new Map<Bindable<any>, ServiceProvider>()
28
+ private booted = false
29
+
30
+ private constructor(private readonly basePath: string = process.cwd()) {
31
+ super()
32
+ // Register the application itself so it can be resolved from the container
33
+ this.instance(Application, this)
34
+ }
35
+
36
+ // ── Singleton access ──────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Create and configure the application.
40
+ * Loads config immediately so config() is available before any provider runs.
41
+ *
42
+ * @param basePath - Project root directory
43
+ * @param configPath - Path to config directory (relative to basePath)
44
+ */
45
+ static async create(
46
+ basePath: string = process.cwd(),
47
+ configPath: string = 'config',
48
+ ): Promise<Application> {
49
+ const app = new Application(basePath)
50
+ await app.loadConfig(configPath)
51
+ Application._instance = app
52
+ return app
53
+ }
54
+
55
+ /**
56
+ * Return the global Application instance.
57
+ * @throws Error if the application has not been created yet.
58
+ */
59
+ static getInstance(): Application {
60
+ if (!Application._instance) {
61
+ throw new Error(
62
+ 'Application has not been created. Call Application.create() first.',
63
+ )
64
+ }
65
+ return Application._instance
66
+ }
67
+
68
+ /**
69
+ * Set the global instance (useful for testing).
70
+ */
71
+ static setInstance(app: Application): void {
72
+ Application._instance = app
73
+ }
74
+
75
+ /**
76
+ * Destroy the global instance (useful for testing).
77
+ */
78
+ static resetInstance(): void {
79
+ Application._instance = null
80
+ }
81
+
82
+ // ── Config ────────────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Load config files from the given directory.
86
+ * Config is available immediately after this call via config() and app.make(ConfigRepository).
87
+ *
88
+ * Falls back to a cached config file at bootstrap/cache/config.json if present.
89
+ */
90
+ async loadConfig(configDir: string): Promise<void> {
91
+ const cachePath = `${this.basePath}/bootstrap/cache/config.json`
92
+ let config: ConfigRepository
93
+
94
+ // Check for cached config first (production optimization)
95
+ try {
96
+ const cacheFile = Bun.file(cachePath)
97
+ if (await cacheFile.exists()) {
98
+ const data = await cacheFile.json()
99
+ config = new ConfigRepository(data)
100
+ this.instance(ConfigRepository, config)
101
+ return
102
+ }
103
+ } catch {
104
+ // No cache — load from files
105
+ }
106
+
107
+ const fullConfigPath = configDir.startsWith('/')
108
+ ? configDir
109
+ : `${this.basePath}/${configDir}`
110
+
111
+ config = await ConfigRepository.fromDirectory(fullConfigPath)
112
+ this.instance(ConfigRepository, config)
113
+ }
114
+
115
+ /**
116
+ * Get the config repository directly (no container lookup overhead).
117
+ */
118
+ config(): ConfigRepository {
119
+ return this.make(ConfigRepository)
120
+ }
121
+
122
+ // ── Base path helpers ─────────────────────────────────────────────────────
123
+
124
+ basePath_(path: string = ''): string {
125
+ return path ? `${this.basePath}/${path}` : this.basePath
126
+ }
127
+
128
+ configPath(path: string = ''): string {
129
+ return this.basePath_(path ? `config/${path}` : 'config')
130
+ }
131
+
132
+ storagePath(path: string = ''): string {
133
+ return this.basePath_(path ? `storage/${path}` : 'storage')
134
+ }
135
+
136
+ // ── Provider lifecycle ────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Register all service providers.
140
+ * - Deferred providers are indexed but not registered until their bindings are first resolved.
141
+ * - All non-deferred register() calls complete before any boot() is called.
142
+ */
143
+ async registerProviders(providerClasses: Constructor<ServiceProvider>[]): Promise<void> {
144
+ const nonDeferred: ServiceProvider[] = []
145
+
146
+ for (const ProviderClass of providerClasses) {
147
+ const provider = new ProviderClass(this)
148
+
149
+ if (provider.deferred) {
150
+ // Index by every binding this provider offers
151
+ for (const binding of provider.provides()) {
152
+ this.deferredProviders.set(binding, provider)
153
+ }
154
+ } else {
155
+ await provider.register()
156
+ nonDeferred.push(provider)
157
+ }
158
+ }
159
+
160
+ this.providers = nonDeferred
161
+ }
162
+
163
+ /**
164
+ * Boot all registered (non-deferred) providers.
165
+ * Called after ALL providers have been registered.
166
+ */
167
+ async bootProviders(): Promise<void> {
168
+ for (const provider of this.providers) {
169
+ await provider.boot()
170
+ }
171
+ this.booted = true
172
+ }
173
+
174
+ /**
175
+ * Register a single provider immediately (after initial boot).
176
+ * Useful for testing and dynamic provider loading.
177
+ */
178
+ async register(ProviderClass: Constructor<ServiceProvider>): Promise<void> {
179
+ const provider = new ProviderClass(this)
180
+ await provider.register()
181
+ if (this.booted) {
182
+ await provider.boot()
183
+ } else {
184
+ this.providers.push(provider)
185
+ }
186
+ }
187
+
188
+ // ── Deferred provider resolution ──────────────────────────────────────────
189
+
190
+ /**
191
+ * Override make() to handle deferred provider loading.
192
+ * If a binding isn't found in the container, check deferred providers.
193
+ */
194
+ make<T>(abstract: Bindable<T>): T {
195
+ try {
196
+ return super.make(abstract)
197
+ } catch (err) {
198
+ if (
199
+ err instanceof ContainerResolutionError &&
200
+ err.reason === 'not_bound'
201
+ ) {
202
+ const deferredProvider = this.deferredProviders.get(abstract)
203
+ if (deferredProvider) {
204
+ // Remove from deferred map so we don't loop
205
+ for (const [key, p] of this.deferredProviders.entries()) {
206
+ if (p === deferredProvider) this.deferredProviders.delete(key)
207
+ }
208
+ // Register + boot the deferred provider now
209
+ const boot = async () => {
210
+ await deferredProvider.register()
211
+ await deferredProvider.boot()
212
+ this.providers.push(deferredProvider)
213
+ }
214
+ // @internal: sync wrapper — deferred providers must not have async register/boot
215
+ // that relies on other async operations. In practice this is fine.
216
+ void boot()
217
+ return super.make(abstract)
218
+ }
219
+ }
220
+ throw err
221
+ }
222
+ }
223
+
224
+ // ── Environment ───────────────────────────────────────────────────────────
225
+
226
+ environment(): string {
227
+ return process.env['APP_ENV'] ?? 'production'
228
+ }
229
+
230
+ isLocal(): boolean {
231
+ return this.environment() === 'local'
232
+ }
233
+
234
+ isProduction(): boolean {
235
+ return this.environment() === 'production'
236
+ }
237
+
238
+ isDebug(): boolean {
239
+ return process.env['APP_DEBUG'] === 'true'
240
+ }
241
+ }
@@ -0,0 +1,180 @@
1
+ import type { CacheStore } from '../contracts/Cache.ts'
2
+ import type { DriverManager } from '../contracts/DriverManager.ts'
3
+ import type { EventDispatcher } from '../contracts/EventDispatcher.ts'
4
+ import { MemoryCacheStore } from './MemoryCacheStore.ts'
5
+ import { FileCacheStore } from './FileCacheStore.ts'
6
+ import { NullCacheStore } from './NullCacheStore.ts'
7
+ import { CacheHit, CacheMissed, KeyWritten, KeyForgotten } from './events.ts'
8
+ import type { RedisCacheConfig } from './RedisCacheStore.ts'
9
+ import type { MemcachedCacheConfig } from './MemcachedCacheStore.ts'
10
+
11
+ export interface CacheConfig {
12
+ default: string
13
+ prefix?: string
14
+ stores: {
15
+ memory?: Record<string, unknown>
16
+ file?: { path: string }
17
+ redis?: RedisCacheConfig
18
+ memcached?: MemcachedCacheConfig
19
+ null?: Record<string, unknown>
20
+ [name: string]: Record<string, unknown> | RedisCacheConfig | MemcachedCacheConfig | undefined
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Multi-driver cache manager (Laravel-style).
26
+ *
27
+ * Built-in drivers: memory, file, redis, memcached, null.
28
+ * Custom drivers can be added via `extend()`.
29
+ */
30
+ export class CacheManager implements DriverManager<CacheStore>, CacheStore {
31
+ private readonly config: CacheConfig
32
+ private readonly stores = new Map<string, CacheStore>()
33
+ private readonly customCreators = new Map<string, () => CacheStore>()
34
+
35
+ /** Optional event dispatcher. Set by @mantiq/events when installed. */
36
+ static _dispatcher: EventDispatcher | null = null
37
+
38
+ constructor(config?: Partial<CacheConfig>) {
39
+ this.config = {
40
+ default: config?.default ?? 'memory',
41
+ stores: config?.stores ?? {},
42
+ }
43
+ }
44
+
45
+ // ── DriverManager ───────────────────────────────────────────────────────
46
+
47
+ driver(name?: string): CacheStore {
48
+ const storeName = name ?? this.getDefaultDriver()
49
+
50
+ if (!this.stores.has(storeName)) {
51
+ this.stores.set(storeName, this.createDriver(storeName))
52
+ }
53
+
54
+ return this.stores.get(storeName)!
55
+ }
56
+
57
+ /** Alias for `driver()` — Laravel compatibility. */
58
+ store(name?: string): CacheStore {
59
+ return this.driver(name)
60
+ }
61
+
62
+ extend(name: string, factory: () => CacheStore): void {
63
+ this.customCreators.set(name, factory)
64
+ }
65
+
66
+ getDefaultDriver(): string {
67
+ return this.config.default
68
+ }
69
+
70
+ // ── CacheStore (delegates to default store) ─────────────────────────────
71
+
72
+ async get<T = unknown>(key: string): Promise<T | undefined> {
73
+ const value = await this.driver().get<T>(key)
74
+ const storeName = this.getDefaultDriver()
75
+ if (value !== undefined) {
76
+ await CacheManager._dispatcher?.emit(new CacheHit(key, value, storeName))
77
+ } else {
78
+ await CacheManager._dispatcher?.emit(new CacheMissed(key, storeName))
79
+ }
80
+ return value
81
+ }
82
+
83
+ async put(key: string, value: unknown, ttl?: number): Promise<void> {
84
+ await this.driver().put(key, value, ttl)
85
+ await CacheManager._dispatcher?.emit(new KeyWritten(key, value, ttl, this.getDefaultDriver()))
86
+ }
87
+
88
+ async forget(key: string): Promise<boolean> {
89
+ const result = await this.driver().forget(key)
90
+ await CacheManager._dispatcher?.emit(new KeyForgotten(key, this.getDefaultDriver()))
91
+ return result
92
+ }
93
+
94
+ async has(key: string): Promise<boolean> {
95
+ return this.driver().has(key)
96
+ }
97
+
98
+ async flush(): Promise<void> {
99
+ return this.driver().flush()
100
+ }
101
+
102
+ async increment(key: string, value = 1): Promise<number> {
103
+ return this.driver().increment(key, value)
104
+ }
105
+
106
+ async decrement(key: string, value = 1): Promise<number> {
107
+ return this.driver().decrement(key, value)
108
+ }
109
+
110
+ async add(key: string, value: unknown, ttl?: number): Promise<boolean> {
111
+ return this.driver().add(key, value, ttl)
112
+ }
113
+
114
+ // ── Convenience methods ───────────────────────────────────────────────
115
+
116
+ /**
117
+ * Get an item from the cache, or execute the given callback and store the result.
118
+ */
119
+ async remember<T = unknown>(key: string, ttl: number | undefined, callback: () => T | Promise<T>): Promise<T> {
120
+ const cached = await this.get<T>(key)
121
+ if (cached !== undefined) return cached
122
+
123
+ const value = await callback()
124
+ await this.put(key, value, ttl)
125
+ return value
126
+ }
127
+
128
+ /**
129
+ * Get an item from the cache, or execute the given callback and store the result forever.
130
+ */
131
+ async rememberForever<T = unknown>(key: string, callback: () => T | Promise<T>): Promise<T> {
132
+ return this.remember<T>(key, undefined, callback)
133
+ }
134
+
135
+ /**
136
+ * Retrieve an item from the cache and delete it.
137
+ */
138
+ async pull<T = unknown>(key: string): Promise<T | undefined> {
139
+ const value = await this.get<T>(key)
140
+ if (value !== undefined) {
141
+ await this.forget(key)
142
+ }
143
+ return value
144
+ }
145
+
146
+ /**
147
+ * Store an item in the cache indefinitely.
148
+ */
149
+ async forever(key: string, value: unknown): Promise<void> {
150
+ return this.put(key, value)
151
+ }
152
+
153
+ // ── Internal ────────────────────────────────────────────────────────────
154
+
155
+ private createDriver(name: string): CacheStore {
156
+ const custom = this.customCreators.get(name)
157
+ if (custom) return custom()
158
+
159
+ const storeConfig = this.config.stores[name] ?? {}
160
+
161
+ switch (name) {
162
+ case 'memory':
163
+ return new MemoryCacheStore()
164
+ case 'file':
165
+ return new FileCacheStore((storeConfig as { path?: string }).path ?? '/tmp/mantiq-cache')
166
+ case 'redis': {
167
+ const { RedisCacheStore } = require('./RedisCacheStore.ts')
168
+ return new RedisCacheStore(storeConfig as RedisCacheConfig)
169
+ }
170
+ case 'memcached': {
171
+ const { MemcachedCacheStore } = require('./MemcachedCacheStore.ts')
172
+ return new MemcachedCacheStore(storeConfig as MemcachedCacheConfig)
173
+ }
174
+ case 'null':
175
+ return new NullCacheStore()
176
+ default:
177
+ throw new Error(`Unsupported cache driver: ${name}. Use extend() to register custom drivers.`)
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,113 @@
1
+ import type { CacheStore } from '../contracts/Cache.ts'
2
+ import { join } from 'node:path'
3
+ import { mkdir, rm, readdir } from 'node:fs/promises'
4
+
5
+ interface FileCachePayload {
6
+ value: unknown
7
+ expiresAt: number | null
8
+ }
9
+
10
+ /**
11
+ * File-based cache store. Survives process restarts.
12
+ * Each key maps to a JSON file in the configured directory.
13
+ */
14
+ export class FileCacheStore implements CacheStore {
15
+ private readonly directory: string
16
+ private initialized = false
17
+
18
+ constructor(directory: string) {
19
+ this.directory = directory
20
+ }
21
+
22
+ async get<T = unknown>(key: string): Promise<T | undefined> {
23
+ await this.ensureDirectory()
24
+ const file = Bun.file(this.path(key))
25
+
26
+ if (!(await file.exists())) return undefined
27
+
28
+ try {
29
+ const payload: FileCachePayload = await file.json()
30
+
31
+ if (payload.expiresAt !== null && Date.now() > payload.expiresAt) {
32
+ await this.forget(key)
33
+ return undefined
34
+ }
35
+
36
+ return payload.value as T
37
+ } catch {
38
+ // Corrupted file — remove it
39
+ await this.forget(key)
40
+ return undefined
41
+ }
42
+ }
43
+
44
+ async put(key: string, value: unknown, ttl?: number): Promise<void> {
45
+ await this.ensureDirectory()
46
+ const payload: FileCachePayload = {
47
+ value,
48
+ expiresAt: ttl != null ? Date.now() + ttl * 1000 : null,
49
+ }
50
+ await Bun.write(this.path(key), JSON.stringify(payload))
51
+ }
52
+
53
+ async forget(key: string): Promise<boolean> {
54
+ try {
55
+ const file = Bun.file(this.path(key))
56
+ if (await file.exists()) {
57
+ await rm(this.path(key))
58
+ return true
59
+ }
60
+ return false
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ async has(key: string): Promise<boolean> {
67
+ return (await this.get(key)) !== undefined
68
+ }
69
+
70
+ async flush(): Promise<void> {
71
+ try {
72
+ const files = await readdir(this.directory)
73
+ await Promise.all(
74
+ files
75
+ .filter((f) => f.endsWith('.cache'))
76
+ .map((f) => rm(join(this.directory, f))),
77
+ )
78
+ } catch {
79
+ // Directory might not exist yet — that's fine
80
+ }
81
+ }
82
+
83
+ async increment(key: string, value = 1): Promise<number> {
84
+ const current = await this.get<number>(key)
85
+ const newValue = (current ?? 0) + value
86
+ await this.put(key, newValue)
87
+ return newValue
88
+ }
89
+
90
+ async decrement(key: string, value = 1): Promise<number> {
91
+ return this.increment(key, -value)
92
+ }
93
+
94
+ async add(key: string, value: unknown, ttl?: number): Promise<boolean> {
95
+ if (await this.has(key)) return false
96
+ await this.put(key, value, ttl)
97
+ return true
98
+ }
99
+
100
+ // ── Internal ────────────────────────────────────────────────────────────
101
+
102
+ private path(key: string): string {
103
+ // Hash the key to avoid filesystem issues with special characters
104
+ const safe = Buffer.from(key).toString('hex')
105
+ return join(this.directory, `${safe}.cache`)
106
+ }
107
+
108
+ private async ensureDirectory(): Promise<void> {
109
+ if (this.initialized) return
110
+ await mkdir(this.directory, { recursive: true })
111
+ this.initialized = true
112
+ }
113
+ }