@spfn/core 0.3.0-beta.4 → 0.3.0-beta.6

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 (45) hide show
  1. package/README.md +183 -4
  2. package/dist/authz/index.js +1 -381
  3. package/dist/authz/index.js.map +1 -1
  4. package/dist/db/index.d.ts +173 -27
  5. package/dist/db/index.js +192 -57
  6. package/dist/db/index.js.map +1 -1
  7. package/dist/env/loader.js +24 -1
  8. package/dist/env/loader.js.map +1 -1
  9. package/dist/errors/index.js +1 -381
  10. package/dist/errors/index.js.map +1 -1
  11. package/dist/logger/index.js +0 -12
  12. package/dist/logger/index.js.map +1 -1
  13. package/dist/middleware/index.js +6 -387
  14. package/dist/middleware/index.js.map +1 -1
  15. package/dist/nextjs/index.d.ts +18 -1
  16. package/dist/nextjs/index.js +40 -1
  17. package/dist/nextjs/index.js.map +1 -1
  18. package/dist/nextjs/server.d.ts +34 -1
  19. package/dist/nextjs/server.js +14 -0
  20. package/dist/nextjs/server.js.map +1 -1
  21. package/dist/ops/index.d.ts +61 -6
  22. package/dist/ops/index.js +330 -30
  23. package/dist/ops/index.js.map +1 -1
  24. package/dist/server/index.js +24 -1
  25. package/dist/server/index.js.map +1 -1
  26. package/docs/file-upload.md +195 -333
  27. package/package.json +6 -5
  28. package/src/cache/README.md +330 -0
  29. package/src/codegen/README.md +516 -0
  30. package/src/config/README.md +326 -0
  31. package/src/contract/README.md +326 -0
  32. package/src/db/README.md +589 -0
  33. package/src/db/manager/README.md +500 -0
  34. package/src/db/schema/README.md +344 -0
  35. package/src/db/transaction/README.md +822 -0
  36. package/src/env/README.md +651 -0
  37. package/src/errors/README.md +429 -0
  38. package/src/event/README.md +736 -0
  39. package/src/job/README.md +514 -0
  40. package/src/logger/README.md +321 -0
  41. package/src/middleware/README.md +634 -0
  42. package/src/nextjs/README.md +608 -0
  43. package/src/route/README.md +738 -0
  44. package/src/security/README.md +100 -0
  45. package/src/server/README.md +704 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.3.0-beta.4",
3
+ "version": "0.3.0-beta.6",
4
4
  "description": "Full-stack TypeScript backend for Next.js: file-based typed routes, Drizzle entities and repositories, PostgreSQL transactions and a generated end-to-end client, in one fixed vertical slice per feature",
5
5
  "type": "module",
6
6
  "exports": {
@@ -149,9 +149,9 @@
149
149
  "test:client": "vitest run src/client",
150
150
  "test:middleware": "vitest run src/middleware",
151
151
  "test:env": "vitest run src/env",
152
- "test:cache": "vitest run src/cache --config vitest.integration.config.ts",
153
- "test:db": "vitest run src/db --config vitest.integration.config.ts",
154
- "test:server": "vitest run src/server --config vitest.integration.config.ts",
152
+ "test:cache": "vitest run src/cache",
153
+ "test:db": "vitest run src/db",
154
+ "test:server": "vitest run src/server",
155
155
  "check:circular": "madge --circular --extensions ts src/",
156
156
  "type-check": "tsc --noEmit",
157
157
  "publish:alpha": "node ../../scripts/publish-package.mjs alpha",
@@ -242,7 +242,8 @@
242
242
  "dist",
243
243
  "docs",
244
244
  "README.md",
245
- "LICENSE"
245
+ "LICENSE",
246
+ "src/**/README.md"
246
247
  ],
247
248
  "publishConfig": {
248
249
  "access": "public",
@@ -0,0 +1,330 @@
1
+ # @spfn/core/cache — singleton Valkey/Redis connection manager (graceful-degrading)
2
+
3
+ Manages a process-global cache connection (ioredis `Redis | Cluster`) built from `CACHE_*`
4
+ env vars. `getCache()` / `getCacheRead()` hand you the **raw ioredis client** — there is no
5
+ typed wrapper. When no config is present, the library is missing, or the connection fails,
6
+ the module runs in **disabled mode** (getters return `undefined`) instead of throwing.
7
+
8
+ Valkey is a Redis fork (7.2.4 base) with 100% protocol compatibility, so `redis://` /
9
+ `rediss://` URLs and the ioredis client work unchanged against either.
10
+
11
+ ## Import paths
12
+
13
+ ```typescript
14
+ import {
15
+ getCache, getCacheRead, isCacheDisabled,
16
+ setCache, initCache, closeCache, getCacheInfo,
17
+ createCacheFromEnv, createSingleCacheFromEnv,
18
+ } from '@spfn/core/cache';
19
+ ```
20
+
21
+ Single entry point — there is no `@spfn/core/cache/*` subpath. `ioredis` is an **optional
22
+ peer dependency** (`peerDependenciesMeta.ioredis.optional`); install it only when you want
23
+ cache enabled. It is `import()`-ed dynamically, so without it the bundle never references it.
24
+
25
+ ---
26
+
27
+ ## Public API (complete)
28
+
29
+ Connection manager (`cache-manager.ts`):
30
+
31
+ | Export | Signature | Notes |
32
+ |--------|-----------|-------|
33
+ | `getCache()` | `() => Redis \| Cluster \| undefined` | Write instance. `undefined` if disabled/uninit. |
34
+ | `getCacheRead()` | `() => Redis \| Cluster \| undefined` | Read instance; falls back to write (`state.read ?? state.write`). |
35
+ | `isCacheDisabled()` | `() => boolean` | True when not configured / lib missing / connect failed. |
36
+ | `setCache(write, read?)` | `(Redis\|Cluster\|undefined, Redis\|Cluster\|undefined?) => void` | Manual/test injection. `read` defaults to `write`. Sets `disabled = !write`. |
37
+ | `initCache()` | `() => Promise<{ write?, read?, disabled: boolean }>` | `ping()`-tests then registers. Called by `startServer()`. |
38
+ | `closeCache()` | `() => Promise<void>` | `quit()`s connections, resets to disabled. Graceful-shutdown safe. |
39
+ | `getCacheInfo()` | `() => { hasWrite: boolean; hasRead: boolean; isReplica: boolean; disabled: boolean }` | Debug snapshot. |
40
+
41
+ Factory (`cache-factory.ts`):
42
+
43
+ | Export | Signature | Notes |
44
+ |--------|-----------|-------|
45
+ | `createCacheFromEnv()` | `() => Promise<CacheClients>` | Builds client(s) from env. Does **not** `ping` or register globally. |
46
+ | `createSingleCacheFromEnv()` | `() => Promise<Redis \| Cluster \| undefined>` | Returns only `write` from the above. |
47
+
48
+ Aliases & types:
49
+
50
+ - `createRedisFromEnv` = `createCacheFromEnv`, `createSingleRedisFromEnv` = `createSingleCacheFromEnv` (backward-compat).
51
+ - `type CacheClients = { write?: Redis \| Cluster; read?: Redis \| Cluster }`.
52
+ - `type RedisClients = CacheClients` (alias).
53
+
54
+ > **No such API.** There is **no** `cache` singleton object and **no** typed convenience
55
+ > wrapper. `cache.get<T>(...)`, `cache.set(key, value, { ttl })`, `cache.exists`,
56
+ > `cache.prefix(...)`, `cache.hset/hget/hgetall`, `cache.lpush/lrange` **as methods on a
57
+ > `cache` export do not exist** — older docs showing `import { cache } from '@spfn/core/cache'`
58
+ > are stale. You call those Redis commands directly on the ioredis instance returned by
59
+ > `getCache()` (e.g. `getCache()?.set(...)`, `getCache()?.hset(...)`). Likewise the env var is
60
+ > **`CACHE_URL`**, not `REDIS_URL` — `REDIS_URL` is ignored by the factory.
61
+
62
+ ---
63
+
64
+ ## Quick Start
65
+
66
+ ```typescript
67
+ import { startServer } from '@spfn/core/server';
68
+
69
+ await startServer(); // calls initCache() — cache enabled if CACHE_URL is set, else disabled
70
+ ```
71
+
72
+ ```typescript
73
+ import { getCache, getCacheRead, isCacheDisabled } from '@spfn/core/cache';
74
+
75
+ // Write (raw ioredis client). Always null-check — may be undefined in disabled mode.
76
+ const cache = getCache();
77
+ if (cache)
78
+ {
79
+ await cache.set('user:123', JSON.stringify({ name: 'John' }));
80
+ }
81
+
82
+ // Read (replica if configured, else same as write)
83
+ const value = await getCacheRead()?.get('user:123');
84
+ const user = value ? JSON.parse(value) : null;
85
+
86
+ // Or branch on disabled mode
87
+ if (isCacheDisabled())
88
+ {
89
+ return fetchFromDatabase(); // alternative path
90
+ }
91
+ ```
92
+
93
+ Install ioredis to enable cache (optional):
94
+
95
+ ```bash
96
+ pnpm add ioredis # ioredis speaks both Valkey and Redis
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Environment variables
102
+
103
+ `createCacheFromEnv()` reads only `CACHE_*` vars, in this priority order:
104
+
105
+ | Pattern | Vars | Result |
106
+ |---------|------|--------|
107
+ | Single (most common) | `CACHE_URL` | one client used for both read & write |
108
+ | Master-Replica | `CACHE_WRITE_URL` + `CACHE_READ_URL` (both required) | separate write/read clients |
109
+ | Sentinel | `CACHE_SENTINEL_HOSTS` + `CACHE_MASTER_NAME` (+ `CACHE_PASSWORD`) | one client via sentinels |
110
+ | Cluster | `CACHE_CLUSTER_NODES` (+ `CACHE_PASSWORD`) | one `Cluster` client |
111
+ | TLS | `CACHE_TLS_REJECT_UNAUTHORIZED` | applied when URL is `rediss://` |
112
+
113
+ ```bash
114
+ # Single
115
+ CACHE_URL=redis://localhost:6379
116
+ CACHE_URL=redis://:<password>@host:6379 # with auth (do not commit real secrets)
117
+ CACHE_URL=rediss://secure.host:6380 # TLS
118
+ CACHE_TLS_REJECT_UNAUTHORIZED=false # accept self-signed cert (rediss:// only)
119
+
120
+ # Master-Replica (BOTH must be set, else this pattern is skipped)
121
+ CACHE_WRITE_URL=redis://master:6379
122
+ CACHE_READ_URL=redis://replica:6379
123
+
124
+ # Sentinel
125
+ CACHE_SENTINEL_HOSTS=sentinel1:26379,sentinel2:26379
126
+ CACHE_MASTER_NAME=mymaster
127
+
128
+ # Cluster
129
+ CACHE_CLUSTER_NODES=node1:6379,node2:6379,node3:6379
130
+ ```
131
+
132
+ `hasCacheConfig()` checks `CACHE_URL`, `CACHE_WRITE_URL`, `CACHE_READ_URL`,
133
+ `CACHE_SENTINEL_HOSTS`, or `CACHE_CLUSTER_NODES`. If none are set, the factory short-circuits
134
+ to `{ write: undefined, read: undefined }` (disabled) **without importing ioredis**.
135
+
136
+ ---
137
+
138
+ ## Lifecycle: factory vs manager
139
+
140
+ - **`createCacheFromEnv()`** is pure construction: it builds and returns ioredis client(s)
141
+ but never pings them and never touches global state. Use it for custom wiring/tests.
142
+ - **`initCache()`** is the managed path: it calls the factory, **`ping()`-tests** write (and
143
+ read, if distinct), and only then registers into the global singleton. On ping failure it
144
+ `quit()`s the connections and sets `disabled = true`. `startServer()` calls this for you.
145
+ - The singleton lives on `globalThis[Symbol.for('@spfn/core:cache')]`, so it survives the
146
+ CJS/ESM dual-package hazard (one shared state even if the module loads twice). All
147
+ consumers (`@spfn/auth`, your app, etc.) share the exact same connection.
148
+
149
+ ```typescript
150
+ import { initCache, getCacheInfo, closeCache } from '@spfn/core/cache';
151
+
152
+ const { disabled } = await initCache(); // idempotent: returns existing state if already init'd
153
+ console.log(getCacheInfo()); // { hasWrite, hasRead, isReplica, disabled }
154
+
155
+ process.on('SIGTERM', async () =>
156
+ {
157
+ await closeCache();
158
+ process.exit(0);
159
+ });
160
+ ```
161
+
162
+ `initCache()` is idempotent via `if (state.write) return …` — calling it again when already
163
+ connected returns the current state without re-pinging.
164
+
165
+ ---
166
+
167
+ ## Using the client (raw ioredis)
168
+
169
+ `getCache()` returns a standard ioredis `Redis | Cluster`. All commands are the ioredis API,
170
+ not an SPFN wrapper — values are strings/buffers, **no automatic JSON serialization**, and
171
+ **TTL is the ioredis native form** (`'EX', seconds` or `'PX', ms`), not a `{ ttl }` option.
172
+
173
+ ```typescript
174
+ const cache = getCache();
175
+ if (cache)
176
+ {
177
+ // Strings + TTL (seconds via EX; milliseconds via PX)
178
+ await cache.set('session:abc', JSON.stringify(data), 'EX', 3600); // expires in 1h
179
+ await cache.set('flag', '1', 'PX', 500); // expires in 500ms
180
+ await cache.expire('session:abc', 60); // (re)set TTL, seconds
181
+ const ttl = await cache.ttl('session:abc'); // seconds remaining
182
+
183
+ // Existence / delete / counters
184
+ await cache.exists('session:abc'); // 1 | 0
185
+ await cache.del('session:abc');
186
+ await cache.incr('counter');
187
+
188
+ // Hashes
189
+ await cache.hset('user:123', 'name', 'John');
190
+ await cache.hget('user:123', 'name');
191
+ await cache.hgetall('user:123'); // Record<string,string>
192
+
193
+ // Lists
194
+ await cache.rpush('queue', 'item');
195
+ await cache.lrange('queue', 0, -1);
196
+ }
197
+
198
+ // Reads can target the replica
199
+ await getCacheRead()?.get('session:abc');
200
+ ```
201
+
202
+ For full command coverage see the ioredis docs — anything ioredis supports works here.
203
+
204
+ ---
205
+
206
+ ## Testing
207
+
208
+ `setCache()` injects instances (real or mock) without touching env or pinging:
209
+
210
+ ```typescript
211
+ import { setCache, isCacheDisabled } from '@spfn/core/cache';
212
+ import { vi } from 'vitest';
213
+
214
+ beforeAll(() =>
215
+ {
216
+ setCache({ get: vi.fn(), set: vi.fn(), del: vi.fn(),
217
+ ping: vi.fn().mockResolvedValue('PONG'),
218
+ quit: vi.fn().mockResolvedValue('OK') } as any);
219
+ });
220
+
221
+ afterAll(() => setCache(undefined)); // undefined → disabled mode
222
+
223
+ it('reports enabled', () => expect(isCacheDisabled()).toBe(false));
224
+ ```
225
+
226
+ `setCache(undefined)` flips the module to disabled mode (`disabled = !write`), which is the
227
+ simplest way to exercise your degradation path.
228
+
229
+ ---
230
+
231
+ ## Pitfalls & anti-patterns
232
+
233
+ - **There is no `cache` object / typed wrapper.** Don't write
234
+ `import { cache } from '@spfn/core/cache'` or `cache.get<T>(k)` / `cache.set(k, v, { ttl })`
235
+ / `cache.prefix(...)`. Use `getCache()` / `getCacheRead()` and call ioredis commands on the
236
+ returned client.
237
+ - **Always null-check the getter.** `getCache()` / `getCacheRead()` return `undefined` in
238
+ disabled mode. Use optional chaining (`getCache()?.set(...)`) or an `if (cache)` guard —
239
+ calling a method on `undefined` throws.
240
+ - **No automatic serialization.** ioredis stores strings/buffers. `JSON.stringify` on write
241
+ and `JSON.parse` on read yourself; passing an object stores `"[object Object]"`.
242
+ - **TTL is ioredis-native, not `{ ttl }`.** Use `set(key, val, 'EX', seconds)` /
243
+ `'PX', ms`, or a separate `expire(key, seconds)`. `EX` is seconds, `PX` is milliseconds —
244
+ don't mix the units up. There is no SPFN `{ ttl }` option object.
245
+ - **Env var is `CACHE_URL`, not `REDIS_URL`.** The factory only inspects `CACHE_*` keys;
246
+ `REDIS_URL` (and other `REDIS_*`) are ignored and leave you in disabled mode. (The
247
+ manager's JSDoc mentioning `VALKEY_*` / legacy `REDIS_*` is aspirational — the factory does
248
+ not read them.)
249
+ - **Master-Replica needs BOTH URLs.** Setting only `CACHE_WRITE_URL` (without
250
+ `CACHE_READ_URL`) skips the replica branch; if `CACHE_URL` is also unset you get disabled
251
+ mode. Set both, or just use `CACHE_URL`.
252
+ - **`createCacheFromEnv()` does not ping or register.** It returns possibly-unhealthy clients
253
+ and does not populate the global singleton. For the validated, shared instance use
254
+ `initCache()` (or `startServer()`); `getCache()` only sees instances registered via
255
+ `initCache()` / `setCache()`.
256
+ - **`closeCache()` is a no-op while disabled** and resets state to disabled afterward. After
257
+ closing, `getCache()` returns `undefined` until the next `initCache()` — don't cache the
258
+ client reference across a shutdown.
259
+ - **Secrets in URLs.** `CACHE_URL` / `CACHE_PASSWORD` carry credentials — keep them in
260
+ gitignored env files (`.env.server`), never commit real values. The factory masks the
261
+ password in its debug log (`:***@`); your own logging should do the same.
262
+
263
+ ---
264
+
265
+ ## Complete example
266
+
267
+ ```typescript
268
+ // cache-aside with the raw client + graceful degradation
269
+ import { getCache, getCacheRead } from '@spfn/core/cache';
270
+
271
+ async function getUser(id: string): Promise<User | null>
272
+ {
273
+ const cached = await getCacheRead()?.get(`user:${id}`);
274
+ if (cached)
275
+ {
276
+ return JSON.parse(cached) as User;
277
+ }
278
+
279
+ const user = await userRepo.findById(id);
280
+ if (user)
281
+ {
282
+ // 1h TTL; manual JSON serialization. No-op if cache disabled (optional chaining).
283
+ await getCache()?.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);
284
+ }
285
+ return user;
286
+ }
287
+
288
+ async function updateUser(id: string, data: Partial<User>): Promise<User>
289
+ {
290
+ const user = await userRepo.update(id, data);
291
+ await getCache()?.del(`user:${id}`); // invalidate
292
+ return user;
293
+ }
294
+ ```
295
+
296
+ ```typescript
297
+ // manual wiring without env (e.g. custom config / one-off scripts)
298
+ import { setCache, getCache, closeCache } from '@spfn/core/cache';
299
+ import Redis from 'ioredis';
300
+
301
+ setCache(new Redis({ host: 'localhost', port: 6379, db: 0 }));
302
+ await getCache()?.set('k', 'v');
303
+ await closeCache();
304
+ ```
305
+
306
+ ---
307
+
308
+ ## Types reference
309
+
310
+ ```typescript
311
+ interface CacheClients
312
+ {
313
+ write?: Redis | Cluster; // primary (also read if no replica)
314
+ read?: Redis | Cluster; // replica; getCacheRead() falls back to write
315
+ }
316
+ type RedisClients = CacheClients; // backward-compat alias
317
+
318
+ // getCacheInfo() return shape
319
+ { hasWrite: boolean; hasRead: boolean; isReplica: boolean; disabled: boolean }
320
+ ```
321
+
322
+ `Redis` / `Cluster` are ioredis types (`import('ioredis')`).
323
+
324
+ ## Related
325
+
326
+ - [@spfn/core/env](../env/README.md) — defining/validating `CACHE_*` vars
327
+ - [@spfn/core/logger](../logger/README.md) — the `@spfn/core:cache` child logger this module uses
328
+ - [@spfn/core](../../README.md) — main package documentation
329
+ - [ioredis](https://github.com/redis/ioredis) — full command/client API for the returned instance
330
+ - [Valkey](https://valkey.io/docs/) — protocol-compatible Redis fork