@alfredmouelle/create-stack 0.1.2 → 0.2.0

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 (64) hide show
  1. package/README.md +42 -14
  2. package/_stack/packages/analytics/capability.json +26 -0
  3. package/_stack/packages/analytics/package.json +26 -0
  4. package/_stack/packages/analytics/src/adapters/noop/index.ts +12 -0
  5. package/_stack/packages/analytics/src/adapters/plausible/config.ts +10 -0
  6. package/_stack/packages/analytics/src/adapters/plausible/index.ts +94 -0
  7. package/_stack/packages/analytics/src/adapters/posthog/config.ts +7 -0
  8. package/_stack/packages/analytics/src/adapters/posthog/index.ts +50 -0
  9. package/_stack/packages/analytics/src/core/port.ts +30 -0
  10. package/_stack/packages/analytics/src/index.ts +17 -0
  11. package/_stack/packages/cache/capability.json +21 -0
  12. package/_stack/packages/cache/package.json +25 -0
  13. package/_stack/packages/cache/src/adapters/memory/index.ts +51 -0
  14. package/_stack/packages/cache/src/adapters/redis/config.ts +8 -0
  15. package/_stack/packages/cache/src/adapters/redis/index.ts +73 -0
  16. package/_stack/packages/cache/src/core/port.ts +29 -0
  17. package/_stack/packages/cache/src/core/wrap.ts +20 -0
  18. package/_stack/packages/cache/src/index.ts +12 -0
  19. package/_stack/packages/error-tracking/capability.json +21 -0
  20. package/_stack/packages/error-tracking/package.json +25 -0
  21. package/_stack/packages/error-tracking/src/adapters/console/index.ts +43 -0
  22. package/_stack/packages/error-tracking/src/adapters/sentry/config.ts +8 -0
  23. package/_stack/packages/error-tracking/src/adapters/sentry/index.ts +72 -0
  24. package/_stack/packages/error-tracking/src/core/port.ts +39 -0
  25. package/_stack/packages/error-tracking/src/index.ts +14 -0
  26. package/_stack/packages/http/package.json +20 -0
  27. package/_stack/packages/http/src/api.ts +373 -0
  28. package/_stack/packages/http/src/index.ts +14 -0
  29. package/_stack/packages/http/src/responses.ts +25 -0
  30. package/_stack/packages/http/src/types.ts +9 -0
  31. package/_stack/packages/jobs/capability.json +26 -0
  32. package/_stack/packages/jobs/package.json +27 -0
  33. package/_stack/packages/jobs/src/adapters/inngest/config.ts +8 -0
  34. package/_stack/packages/jobs/src/adapters/inngest/index.ts +93 -0
  35. package/_stack/packages/jobs/src/adapters/memory/index.ts +31 -0
  36. package/_stack/packages/jobs/src/adapters/trigger/config.ts +8 -0
  37. package/_stack/packages/jobs/src/adapters/trigger/index.ts +85 -0
  38. package/_stack/packages/jobs/src/core/port.ts +37 -0
  39. package/_stack/packages/jobs/src/index.ts +23 -0
  40. package/_stack/packages/logger/capability.json +21 -0
  41. package/_stack/packages/logger/package.json +25 -0
  42. package/_stack/packages/logger/src/adapters/console/config.ts +7 -0
  43. package/_stack/packages/logger/src/adapters/console/index.ts +69 -0
  44. package/_stack/packages/logger/src/adapters/pino/index.ts +54 -0
  45. package/_stack/packages/logger/src/core/port.ts +21 -0
  46. package/_stack/packages/logger/src/index.ts +12 -0
  47. package/_stack/packages/storage/capability.json +32 -0
  48. package/_stack/packages/storage/package.json +27 -0
  49. package/_stack/packages/storage/src/adapters/gcs/config.ts +8 -0
  50. package/_stack/packages/storage/src/adapters/gcs/index.ts +111 -0
  51. package/_stack/packages/storage/src/adapters/local/config.ts +8 -0
  52. package/_stack/packages/storage/src/adapters/local/index.ts +78 -0
  53. package/_stack/packages/storage/src/adapters/r2/config.ts +8 -0
  54. package/_stack/packages/storage/src/adapters/r2/index.ts +39 -0
  55. package/_stack/packages/storage/src/adapters/s3/config.ts +11 -0
  56. package/_stack/packages/storage/src/adapters/s3/index.ts +143 -0
  57. package/_stack/packages/storage/src/core/port.ts +41 -0
  58. package/_stack/packages/storage/src/index.ts +21 -0
  59. package/index.mjs +69 -18
  60. package/lib/build.mjs +23 -5
  61. package/lib/capabilities.mjs +375 -0
  62. package/lib/env.mjs +21 -0
  63. package/lib/scaffold.mjs +1 -0
  64. package/package.json +1 -1
package/README.md CHANGED
@@ -4,9 +4,10 @@
4
4
 
5
5
  Interactive, **deterministic** project installer. It forks a fully-wired base app
6
6
  (**Next.js App Router** or **TanStack Start**) and strips it down to exactly the
7
- foundations and provider you pick — Drizzle, tRPC, better-auth, data tables and a
8
- mailer then stamps identity, generates `.env`, initializes git and verifies the
9
- result (typecheck + Biome).
7
+ foundations and provider you pick — Drizzle, tRPC, better-auth, data tables, a
8
+ mailer and optional capabilities (storage, cache, jobs, logger, analytics,
9
+ error-tracking) then stamps identity, generates `.env`, initializes git and
10
+ verifies the result (typecheck + Biome).
10
11
 
11
12
  No template guesswork: the output is a real, buildable app from day one.
12
13
 
@@ -46,12 +47,20 @@ not exist yet. In non-interactive mode it is required.
46
47
  | `--framework` | `tanstack` \| `next` | `tanstack` | Base app to fork. |
47
48
  | `--foundations` | csv of `drizzle,trpc,better-auth,data-table` | all | Foundations to keep; the rest are stripped. |
48
49
  | `--mailer` | `resend` \| `brevo` \| `ses` \| `none` | `resend` | Mailer provider. `none` is rejected when `better-auth` is kept. |
50
+ | `--storage` | `s3` \| `r2` \| `gcs` \| `local` | `s3` | Object storage capability (omit to skip). |
51
+ | `--cache` | `redis` \| `memory` | `redis` | Key/value cache capability (omit to skip). |
52
+ | `--jobs` | `inngest` \| `trigger` \| `memory` | `inngest` | Background jobs capability (omit to skip). `inngest` also scaffolds the serve route. |
53
+ | `--logger` | `pino` \| `console` | `pino` | Structured logging capability (omit to skip). |
54
+ | `--analytics` | `posthog` \| `plausible` \| `noop` | `posthog` | Product analytics capability (omit to skip). |
55
+ | `--error-tracking` | `sentry` \| `console` | `sentry` | Error reporting capability (omit to skip). |
49
56
  | `--no-install` | — | install on | Skip `pnpm install` + verification. |
50
57
  | `--yes`, `-y` | — | — | Non-interactive with all defaults. |
51
58
 
52
- Passing any of `--framework`, `--foundations`, `--mailer` or `--no-install`
53
- (or `--yes`) switches the CLI to non-interactive mode; missing values fall back
54
- to the defaults above.
59
+ Each capability flag is optional: pass it (bare for the default adapter, or with a
60
+ value) to vendor that capability; omit it to leave it out. Passing any selection
61
+ flag `--framework`, `--foundations`, `--mailer`, any capability, or `--no-install`
62
+ (or `--yes`) — switches the CLI to non-interactive mode; missing values fall back to
63
+ the defaults above.
55
64
 
56
65
  ### Dependency resolution
57
66
 
@@ -75,6 +84,10 @@ pnpm dlx @alfredmouelle/create-stack api --framework next \
75
84
 
76
85
  # Minimal: Drizzle only, no mailer
77
86
  pnpm dlx @alfredmouelle/create-stack db-svc --foundations drizzle --mailer none
87
+
88
+ # With capabilities: R2 storage, Redis cache, Inngest jobs, Sentry errors
89
+ pnpm dlx @alfredmouelle/create-stack my-app \
90
+ --storage r2 --cache --jobs --error-tracking
78
91
  ```
79
92
 
80
93
  ## What you get
@@ -92,12 +105,27 @@ pnpm dlx @alfredmouelle/create-stack db-svc --foundations drizzle --mailer none
92
105
  Unselected foundations are removed cleanly (files, deps, env vars and wiring),
93
106
  and the project is left **bootable and green** (typecheck + Biome).
94
107
 
95
- ## Adding more capabilities
108
+ ## Capabilities
109
+
110
+ Beyond the **mailer** (always baked in, chosen via `--mailer`), the CLI can vendor
111
+ any of the swappable capabilities at scaffold time — pick them in the wizard or via
112
+ flags. Each is copied behind a port (into `src/server/<capability>/`) with a generated
113
+ composition root that reads typed env and constructs the adapter lazily, so the app
114
+ boots even before you fill in the keys:
115
+
116
+ | Capability | Adapters | Notes |
117
+ | --- | --- | --- |
118
+ | `storage` | s3, r2, gcs, local | `getStorage()` accessor. |
119
+ | `cache` | redis, memory | `getCache()` accessor. |
120
+ | `jobs` | inngest, trigger, memory | `inngest` also scaffolds `serve.ts` + the framework route. |
121
+ | `logger` | pino, console | `getLogger()` accessor. |
122
+ | `analytics` | posthog, plausible, noop | `plausible` vendors `~/lib/http`. |
123
+ | `error-tracking` | sentry, console | `getErrorTracking()` accessor. |
96
124
 
97
- The base bakes in the **mailer** (chosen via `--mailer`). Other capabilities
98
- storage, jobs, cache, logger, analytics, error-tracking, http — are added *after*
99
- scaffolding with the `add-capability` skill (it wires each adapter's env/config per
100
- provider, which this CLI deliberately leaves out).
125
+ Adapter deps and env keys are wired into `package.json` and `src/env.ts` automatically;
126
+ cross-package imports (`@alfredmouelle/http`) are vendored into `src/lib/http` and
127
+ rewritten. To add a capability to an **existing** project (or swap an adapter), use the
128
+ `add-capability` skill.
101
129
 
102
130
  ## After scaffolding
103
131
 
@@ -110,8 +138,8 @@ pnpm dev
110
138
 
111
139
  ## Notes
112
140
 
113
- - The published package is **self-contained**: the base apps, pattern manifests
114
- and mailer adapters are bundled at publish time, so `pnpm dlx` needs nothing but
115
- this package.
141
+ - The published package is **self-contained**: the base apps, the mailer adapters
142
+ and every capability package (`+ http`) are bundled at publish time, so `pnpm dlx`
143
+ needs nothing but this package.
116
144
  - The generated project is a fresh git repo (`git init`, files staged) — make your
117
145
  first commit when ready.
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "../../capability.schema.json",
3
+ "name": "analytics",
4
+ "description": "Product analytics behind a swappable port. Capture events and identify users; flush/shutdown to drain pending events.",
5
+ "port": "src/core/port.ts",
6
+ "defaultAdapter": "posthog",
7
+ "adapters": {
8
+ "posthog": {
9
+ "deps": ["posthog-node"],
10
+ "env": ["POSTHOG_API_KEY", "POSTHOG_HOST"],
11
+ "files": ["src/adapters/posthog"]
12
+ },
13
+ "plausible": {
14
+ "deps": ["@alfredmouelle/http"],
15
+ "env": ["PLAUSIBLE_DOMAIN", "PLAUSIBLE_API_HOST"],
16
+ "files": ["src/adapters/plausible"]
17
+ },
18
+ "noop": {
19
+ "deps": [],
20
+ "env": [],
21
+ "files": ["src/adapters/noop"]
22
+ }
23
+ },
24
+ "sharedDeps": ["valibot"],
25
+ "sharedFiles": ["src/core", "src/index.ts"]
26
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@alfredmouelle/analytics",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "build": "tsdown",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "@alfredmouelle/http": "workspace:*",
17
+ "posthog-node": "^5.38.2",
18
+ "valibot": "^1.4.1"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.10.2",
22
+ "tsdown": "^0.22.3",
23
+ "typescript": "^5.9.3",
24
+ "vitest": "^4.1.9"
25
+ }
26
+ }
@@ -0,0 +1,12 @@
1
+ import type { AnalyticsPort } from '../../core/port.js'
2
+
3
+ /** No-op analytics adapter (dev/tests/disabled); call sites still depend on the port. */
4
+ export function noopAdapter(): AnalyticsPort {
5
+ return {
6
+ name: 'noop',
7
+ capture() {},
8
+ identify() {},
9
+ async flush() {},
10
+ async shutdown() {},
11
+ }
12
+ }
@@ -0,0 +1,10 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const PlausibleConfigSchema = v.object({
4
+ /** Site domain as registered in Plausible (e.g. `acme.com`). */
5
+ domain: v.pipe(v.string(), v.minLength(1, 'Plausible domain is required')),
6
+ /** Plausible host. Defaults to `https://plausible.io`. */
7
+ apiHost: v.optional(v.string()),
8
+ })
9
+
10
+ export type PlausibleConfig = v.InferOutput<typeof PlausibleConfigSchema>
@@ -0,0 +1,94 @@
1
+ import { apiFetch } from '@alfredmouelle/http'
2
+ import * as v from 'valibot'
3
+ import type { AnalyticsPort, CaptureEvent } from '../../core/port.js'
4
+ import { PlausibleConfigSchema } from './config.js'
5
+
6
+ const DEFAULT_API_HOST = 'https://plausible.io'
7
+ const DEFAULT_USER_AGENT = '@alfredmouelle/analytics (+https://plausible.io)'
8
+
9
+ export interface PlausibleAdapterOptions {
10
+ /** Site domain as registered in Plausible (e.g. `acme.com`). */
11
+ domain: string
12
+ /** Plausible host. Defaults to `https://plausible.io`. */
13
+ apiHost?: string
14
+ /** Fallback page URL when an event has no `url`. */
15
+ defaultUrl?: string
16
+ /** User-Agent sent to Plausible; it derives the cookieless visitor id from UA + client IP, so use a realistic value. */
17
+ userAgent?: string
18
+ /** Inject a custom fetch (mock/scoped client). */
19
+ fetchImpl?: typeof globalThis.fetch
20
+ /** Called on fire-and-forget request failure; defaults to swallowing. */
21
+ onError?: (error: unknown) => void
22
+ }
23
+
24
+ interface PlausibleEventPayload {
25
+ name: string
26
+ domain: string
27
+ url: string
28
+ referrer?: string
29
+ props?: Record<string, unknown>
30
+ }
31
+
32
+ /**
33
+ * Plausible adapter via server-side Events API (`POST /api/event`). `capture` is
34
+ * fire-and-forget; requests are tracked so `flush`/`shutdown` drain them.
35
+ * Cookieless, no person profiles, so `identify` is a no-op; `distinctId` is
36
+ * forwarded as a `distinct_id` prop for visibility only (counting uses UA + IP).
37
+ * `url`/`referrer`/`ip` read from event `properties`; `url` falls back to `defaultUrl`.
38
+ */
39
+ export function plausibleAdapter(options: PlausibleAdapterOptions): AnalyticsPort {
40
+ const config = v.parse(PlausibleConfigSchema, {
41
+ domain: options.domain,
42
+ apiHost: options.apiHost,
43
+ })
44
+ const apiHost = config.apiHost ?? DEFAULT_API_HOST
45
+ const userAgent = options.userAgent ?? DEFAULT_USER_AGENT
46
+ const defaultUrl = options.defaultUrl ?? `https://${config.domain}/`
47
+ const onError = options.onError ?? (() => {})
48
+ const pending = new Set<Promise<void>>()
49
+
50
+ function send(payload: PlausibleEventPayload, ip?: string): void {
51
+ const headers: Record<string, string> = { 'User-Agent': userAgent }
52
+ if (ip) headers['X-Forwarded-For'] = ip
53
+
54
+ const request = apiFetch('/api/event', {
55
+ method: 'POST',
56
+ baseUrl: apiHost,
57
+ headers,
58
+ body: payload,
59
+ parseAs: 'none',
60
+ fetchImpl: options.fetchImpl,
61
+ })
62
+ .then(() => {})
63
+ .catch(onError)
64
+
65
+ pending.add(request)
66
+ void request.finally(() => pending.delete(request))
67
+ }
68
+
69
+ return {
70
+ name: 'plausible',
71
+ capture(event: CaptureEvent) {
72
+ const { url, referrer, ip, ...rest } = (event.properties ?? {}) as Record<string, unknown>
73
+ send(
74
+ {
75
+ name: event.event,
76
+ domain: config.domain,
77
+ url: typeof url === 'string' ? url : defaultUrl,
78
+ referrer: typeof referrer === 'string' ? referrer : undefined,
79
+ props: { distinct_id: event.distinctId, ...rest },
80
+ },
81
+ typeof ip === 'string' ? ip : undefined,
82
+ )
83
+ },
84
+ identify() {
85
+ // Cookieless, no person profiles — nothing to do.
86
+ },
87
+ async flush() {
88
+ await Promise.all([...pending])
89
+ },
90
+ async shutdown() {
91
+ await Promise.all([...pending])
92
+ },
93
+ }
94
+ }
@@ -0,0 +1,7 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const PostHogConfigSchema = v.object({
4
+ apiKey: v.pipe(v.string(), v.minLength(1, 'POSTHOG_API_KEY is required')),
5
+ })
6
+
7
+ export type PostHogConfig = v.InferOutput<typeof PostHogConfigSchema>
@@ -0,0 +1,50 @@
1
+ import { PostHog } from 'posthog-node'
2
+ import * as v from 'valibot'
3
+ import type { AnalyticsPort, CaptureEvent, IdentifyParams } from '../../core/port.js'
4
+ import { PostHogConfigSchema } from './config.js'
5
+
6
+ /** Minimal structural view of the PostHog client (eases testing). */
7
+ export interface PostHogLike {
8
+ capture(payload: CaptureEvent): void
9
+ identify(payload: IdentifyParams): void
10
+ flush(): Promise<void>
11
+ shutdown(): Promise<void>
12
+ }
13
+
14
+ export interface PostHogAdapterOptions {
15
+ apiKey: string
16
+ /** PostHog host (defaults to PostHog's default). */
17
+ host?: string
18
+ /** Inject a custom/mock client; defaults to a real `PostHog`. */
19
+ client?: PostHogLike
20
+ }
21
+
22
+ export function posthogAdapter(options: PostHogAdapterOptions): AnalyticsPort {
23
+ // Validate early: missing key fails at construction, not at capture().
24
+ const config = v.parse(PostHogConfigSchema, { apiKey: options.apiKey })
25
+ const client: PostHogLike =
26
+ options.client ?? (new PostHog(config.apiKey, { host: options.host }) as unknown as PostHogLike)
27
+
28
+ return {
29
+ name: 'posthog',
30
+ capture(event: CaptureEvent) {
31
+ client.capture({
32
+ distinctId: event.distinctId,
33
+ event: event.event,
34
+ properties: event.properties,
35
+ })
36
+ },
37
+ identify(params: IdentifyParams) {
38
+ client.identify({
39
+ distinctId: params.distinctId,
40
+ properties: params.properties,
41
+ })
42
+ },
43
+ flush() {
44
+ return client.flush()
45
+ },
46
+ shutdown() {
47
+ return client.shutdown()
48
+ },
49
+ }
50
+ }
@@ -0,0 +1,30 @@
1
+ /** Analytics event to capture. Fire-and-forget. */
2
+ export interface CaptureEvent {
3
+ /** Event name, e.g. `'user_signed_up'`. */
4
+ event: string
5
+ /** Stable user/actor id. */
6
+ distinctId: string
7
+ /** Event metadata. */
8
+ properties?: Record<string, unknown>
9
+ }
10
+
11
+ /** Set/update properties on a user/actor. Fire-and-forget. */
12
+ export interface IdentifyParams {
13
+ /** Stable user/actor id. */
14
+ distinctId: string
15
+ /** Person properties to set. */
16
+ properties?: Record<string, unknown>
17
+ }
18
+
19
+ /**
20
+ * App-facing port; swap adapters at the composition root, never this interface.
21
+ * `capture`/`identify` are fire-and-forget (like PostHog SDK): enqueue and return.
22
+ * `flush` drains pending events; `shutdown` flushes + releases before exit.
23
+ */
24
+ export interface AnalyticsPort {
25
+ readonly name: string
26
+ capture(event: CaptureEvent): void
27
+ identify(params: IdentifyParams): void
28
+ flush(): Promise<void>
29
+ shutdown(): Promise<void>
30
+ }
@@ -0,0 +1,17 @@
1
+ export { noopAdapter } from './adapters/noop/index.js'
2
+ export { type PlausibleConfig, PlausibleConfigSchema } from './adapters/plausible/config.js'
3
+ export {
4
+ type PlausibleAdapterOptions,
5
+ plausibleAdapter,
6
+ } from './adapters/plausible/index.js'
7
+ export { type PostHogConfig, PostHogConfigSchema } from './adapters/posthog/config.js'
8
+ export {
9
+ type PostHogAdapterOptions,
10
+ type PostHogLike,
11
+ posthogAdapter,
12
+ } from './adapters/posthog/index.js'
13
+ export type {
14
+ AnalyticsPort,
15
+ CaptureEvent,
16
+ IdentifyParams,
17
+ } from './core/port.js'
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "../../capability.schema.json",
3
+ "name": "cache",
4
+ "description": "Key/value cache behind a swappable port. Values are serialized as JSON for remote stores.",
5
+ "port": "src/core/port.ts",
6
+ "defaultAdapter": "redis",
7
+ "adapters": {
8
+ "redis": {
9
+ "deps": ["ioredis"],
10
+ "env": ["REDIS_URL"],
11
+ "files": ["src/adapters/redis"]
12
+ },
13
+ "memory": {
14
+ "deps": [],
15
+ "env": [],
16
+ "files": ["src/adapters/memory"]
17
+ }
18
+ },
19
+ "sharedDeps": ["valibot"],
20
+ "sharedFiles": ["src/core", "src/index.ts"]
21
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@alfredmouelle/cache",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "build": "tsdown",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "ioredis": "^5.11.1",
17
+ "valibot": "^1.4.1"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.10.2",
21
+ "tsdown": "^0.22.3",
22
+ "typescript": "^5.9.3",
23
+ "vitest": "^4.1.9"
24
+ }
25
+ }
@@ -0,0 +1,51 @@
1
+ import type { CachePort } from '../../core/port.js'
2
+ import { wrapValue } from '../../core/wrap.js'
3
+
4
+ interface Entry {
5
+ value: unknown
6
+ /** Epoch ms expiry; `undefined` = no expiry. */
7
+ expiresAt?: number
8
+ }
9
+
10
+ export interface MemoryAdapterOptions {
11
+ /** Seed store (mostly for tests). */
12
+ store?: Map<string, Entry>
13
+ }
14
+
15
+ /** In-process `Map` cache with lazy per-key expiry; no deps. Dev/tests. */
16
+ export function memoryAdapter(options: MemoryAdapterOptions = {}): CachePort {
17
+ const store = options.store ?? new Map<string, Entry>()
18
+
19
+ function read(key: string): Entry | null {
20
+ const entry = store.get(key)
21
+ if (entry === undefined) return null
22
+ if (entry.expiresAt !== undefined && entry.expiresAt <= Date.now()) {
23
+ store.delete(key)
24
+ return null
25
+ }
26
+ return entry
27
+ }
28
+
29
+ const port: CachePort = {
30
+ name: 'memory',
31
+ async get<T>(key: string) {
32
+ const entry = read(key)
33
+ return entry === null ? null : (entry.value as T)
34
+ },
35
+ async set<T>(key: string, value: T, ttlSeconds?: number) {
36
+ const expiresAt = ttlSeconds === undefined ? undefined : Date.now() + ttlSeconds * 1000
37
+ store.set(key, { value, expiresAt })
38
+ },
39
+ async delete(key: string) {
40
+ store.delete(key)
41
+ },
42
+ async has(key: string) {
43
+ return read(key) !== null
44
+ },
45
+ wrap<T>(key: string, factory: () => Promise<T>, ttlSeconds?: number) {
46
+ return wrapValue(port, key, factory, ttlSeconds)
47
+ },
48
+ }
49
+
50
+ return port
51
+ }
@@ -0,0 +1,8 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const RedisConfigSchema = v.object({
4
+ url: v.optional(v.string()),
5
+ keyPrefix: v.optional(v.string()),
6
+ })
7
+
8
+ export type RedisConfig = v.InferOutput<typeof RedisConfigSchema>
@@ -0,0 +1,73 @@
1
+ import { Redis } from 'ioredis'
2
+ import * as v from 'valibot'
3
+ import { CacheError, type CachePort } from '../../core/port.js'
4
+ import { wrapValue } from '../../core/wrap.js'
5
+ import { RedisConfigSchema } from './config.js'
6
+
7
+ /** Minimal structural view of the Redis client (eases testing). */
8
+ export interface RedisLike {
9
+ get(key: string): Promise<string | null>
10
+ set(key: string, value: string, secondsToken?: 'EX', seconds?: number): Promise<unknown>
11
+ del(key: string): Promise<unknown>
12
+ exists(key: string): Promise<number>
13
+ }
14
+
15
+ export interface RedisAdapterOptions {
16
+ /** Inject a custom/mock client; defaults to a real `Redis`. */
17
+ client?: RedisLike
18
+ /** Connection URL when no `client` is injected. */
19
+ url?: string
20
+ /** Prepended to every key. */
21
+ keyPrefix?: string
22
+ }
23
+
24
+ export function redisAdapter(options: RedisAdapterOptions = {}): CachePort {
25
+ // Validate early: bad option fails at construction, not at use.
26
+ const config = v.parse(RedisConfigSchema, { url: options.url, keyPrefix: options.keyPrefix })
27
+ const defaultClient = () =>
28
+ (config.url ? new Redis(config.url) : new Redis()) as unknown as RedisLike
29
+ const client: RedisLike = options.client ?? defaultClient()
30
+ const prefix = config.keyPrefix ?? ''
31
+ const k = (key: string) => `${prefix}${key}`
32
+
33
+ const port: CachePort = {
34
+ name: 'redis',
35
+ async get<T>(key: string) {
36
+ try {
37
+ const raw = await client.get(k(key))
38
+ if (raw === null) return null
39
+ return JSON.parse(raw) as T
40
+ } catch (cause) {
41
+ throw new CacheError('Failed to read from Redis', { adapter: 'redis', cause })
42
+ }
43
+ },
44
+ async set<T>(key: string, value: T, ttlSeconds?: number) {
45
+ try {
46
+ const json = JSON.stringify(value)
47
+ if (ttlSeconds === undefined) await client.set(k(key), json)
48
+ else await client.set(k(key), json, 'EX', ttlSeconds)
49
+ } catch (cause) {
50
+ throw new CacheError('Failed to write to Redis', { adapter: 'redis', cause })
51
+ }
52
+ },
53
+ async delete(key: string) {
54
+ try {
55
+ await client.del(k(key))
56
+ } catch (cause) {
57
+ throw new CacheError('Failed to delete from Redis', { adapter: 'redis', cause })
58
+ }
59
+ },
60
+ async has(key: string) {
61
+ try {
62
+ return (await client.exists(k(key))) > 0
63
+ } catch (cause) {
64
+ throw new CacheError('Failed to query Redis', { adapter: 'redis', cause })
65
+ }
66
+ },
67
+ wrap<T>(key: string, factory: () => Promise<T>, ttlSeconds?: number) {
68
+ return wrapValue(port, key, factory, ttlSeconds)
69
+ },
70
+ }
71
+
72
+ return port
73
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * App-facing port; swap adapters at the composition root, never this interface.
3
+ * Values are JSON-serialized for remote stores, so stored values must be JSON-serializable.
4
+ */
5
+ export interface CachePort {
6
+ /** Backing adapter name (`redis`, `memory`, …). */
7
+ readonly name: string
8
+ /** Read a value, or `null` if absent/expired. */
9
+ get<T>(key: string): Promise<T | null>
10
+ /** Store a value, optionally expiring after `ttlSeconds`. */
11
+ set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>
12
+ /** Remove a key (no-op if absent). */
13
+ delete(key: string): Promise<void>
14
+ /** Whether a non-expired value exists. */
15
+ has(key: string): Promise<boolean>
16
+ /** Read-through: cached value, else `factory()` stored with `ttlSeconds`. */
17
+ wrap<T>(key: string, factory: () => Promise<T>, ttlSeconds?: number): Promise<T>
18
+ }
19
+
20
+ /** Normalized adapter error; callers never catch backend types. */
21
+ export class CacheError extends Error {
22
+ readonly adapter: string
23
+
24
+ constructor(message: string, options: { adapter: string; cause?: unknown }) {
25
+ super(message, { cause: options.cause })
26
+ this.name = 'CacheError'
27
+ this.adapter = options.adapter
28
+ }
29
+ }
@@ -0,0 +1,20 @@
1
+ /** Minimal read/write pair `wrap` needs; adapters pass their own `get`/`set`. */
2
+ export interface WrapStore {
3
+ get<T>(key: string): Promise<T | null>
4
+ set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>
5
+ }
6
+
7
+ /** Read-through: return cached value, else call `factory()`, store with `ttlSeconds`, return. */
8
+ export async function wrapValue<T>(
9
+ store: WrapStore,
10
+ key: string,
11
+ factory: () => Promise<T>,
12
+ ttlSeconds?: number,
13
+ ): Promise<T> {
14
+ const cached = await store.get<T>(key)
15
+ if (cached !== null) return cached
16
+
17
+ const value = await factory()
18
+ await store.set(key, value, ttlSeconds)
19
+ return value
20
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ type MemoryAdapterOptions,
3
+ memoryAdapter,
4
+ } from './adapters/memory/index.js'
5
+ export { type RedisConfig, RedisConfigSchema } from './adapters/redis/config.js'
6
+ export {
7
+ type RedisAdapterOptions,
8
+ type RedisLike,
9
+ redisAdapter,
10
+ } from './adapters/redis/index.js'
11
+ export type { CachePort } from './core/port.js'
12
+ export { CacheError } from './core/port.js'
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "../../capability.schema.json",
3
+ "name": "error-tracking",
4
+ "description": "Error reporting behind a swappable port. Capture exceptions, messages, breadcrumbs and user context, then ship them to a provider.",
5
+ "port": "src/core/port.ts",
6
+ "defaultAdapter": "sentry",
7
+ "adapters": {
8
+ "sentry": {
9
+ "deps": ["@sentry/node"],
10
+ "env": ["SENTRY_DSN", "SENTRY_ENVIRONMENT"],
11
+ "files": ["src/adapters/sentry"]
12
+ },
13
+ "console": {
14
+ "deps": [],
15
+ "env": [],
16
+ "files": ["src/adapters/console"]
17
+ }
18
+ },
19
+ "sharedDeps": ["valibot"],
20
+ "sharedFiles": ["src/core", "src/index.ts"]
21
+ }