@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.
- package/README.md +42 -14
- package/_stack/packages/analytics/capability.json +26 -0
- package/_stack/packages/analytics/package.json +26 -0
- package/_stack/packages/analytics/src/adapters/noop/index.ts +12 -0
- package/_stack/packages/analytics/src/adapters/plausible/config.ts +10 -0
- package/_stack/packages/analytics/src/adapters/plausible/index.ts +94 -0
- package/_stack/packages/analytics/src/adapters/posthog/config.ts +7 -0
- package/_stack/packages/analytics/src/adapters/posthog/index.ts +50 -0
- package/_stack/packages/analytics/src/core/port.ts +30 -0
- package/_stack/packages/analytics/src/index.ts +17 -0
- package/_stack/packages/cache/capability.json +21 -0
- package/_stack/packages/cache/package.json +25 -0
- package/_stack/packages/cache/src/adapters/memory/index.ts +51 -0
- package/_stack/packages/cache/src/adapters/redis/config.ts +8 -0
- package/_stack/packages/cache/src/adapters/redis/index.ts +73 -0
- package/_stack/packages/cache/src/core/port.ts +29 -0
- package/_stack/packages/cache/src/core/wrap.ts +20 -0
- package/_stack/packages/cache/src/index.ts +12 -0
- package/_stack/packages/error-tracking/capability.json +21 -0
- package/_stack/packages/error-tracking/package.json +25 -0
- package/_stack/packages/error-tracking/src/adapters/console/index.ts +43 -0
- package/_stack/packages/error-tracking/src/adapters/sentry/config.ts +8 -0
- package/_stack/packages/error-tracking/src/adapters/sentry/index.ts +72 -0
- package/_stack/packages/error-tracking/src/core/port.ts +39 -0
- package/_stack/packages/error-tracking/src/index.ts +14 -0
- package/_stack/packages/http/package.json +20 -0
- package/_stack/packages/http/src/api.ts +373 -0
- package/_stack/packages/http/src/index.ts +14 -0
- package/_stack/packages/http/src/responses.ts +25 -0
- package/_stack/packages/http/src/types.ts +9 -0
- package/_stack/packages/jobs/capability.json +26 -0
- package/_stack/packages/jobs/package.json +27 -0
- package/_stack/packages/jobs/src/adapters/inngest/config.ts +8 -0
- package/_stack/packages/jobs/src/adapters/inngest/index.ts +93 -0
- package/_stack/packages/jobs/src/adapters/memory/index.ts +31 -0
- package/_stack/packages/jobs/src/adapters/trigger/config.ts +8 -0
- package/_stack/packages/jobs/src/adapters/trigger/index.ts +85 -0
- package/_stack/packages/jobs/src/core/port.ts +37 -0
- package/_stack/packages/jobs/src/index.ts +23 -0
- package/_stack/packages/logger/capability.json +21 -0
- package/_stack/packages/logger/package.json +25 -0
- package/_stack/packages/logger/src/adapters/console/config.ts +7 -0
- package/_stack/packages/logger/src/adapters/console/index.ts +69 -0
- package/_stack/packages/logger/src/adapters/pino/index.ts +54 -0
- package/_stack/packages/logger/src/core/port.ts +21 -0
- package/_stack/packages/logger/src/index.ts +12 -0
- package/_stack/packages/storage/capability.json +32 -0
- package/_stack/packages/storage/package.json +27 -0
- package/_stack/packages/storage/src/adapters/gcs/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/gcs/index.ts +111 -0
- package/_stack/packages/storage/src/adapters/local/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/local/index.ts +78 -0
- package/_stack/packages/storage/src/adapters/r2/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/r2/index.ts +39 -0
- package/_stack/packages/storage/src/adapters/s3/config.ts +11 -0
- package/_stack/packages/storage/src/adapters/s3/index.ts +143 -0
- package/_stack/packages/storage/src/core/port.ts +41 -0
- package/_stack/packages/storage/src/index.ts +21 -0
- package/index.mjs +69 -18
- package/lib/build.mjs +23 -5
- package/lib/capabilities.mjs +375 -0
- package/lib/env.mjs +21 -0
- package/lib/scaffold.mjs +1 -0
- 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
|
|
8
|
-
mailer
|
|
9
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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,
|
|
114
|
-
and
|
|
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,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,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
|
+
}
|