@alfredmouelle/create-stack 0.1.1 → 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 (127) hide show
  1. package/README.md +41 -18
  2. package/_stack/apps/next-base/.turbo/turbo-typecheck.log +1 -0
  3. package/_stack/apps/next-base/.vscode/settings.json +35 -0
  4. package/_stack/apps/next-base/.zed/settings.json +45 -0
  5. package/_stack/apps/next-base/src/components/ui/spinner.tsx +1 -1
  6. package/_stack/apps/next-base/src/emails/components/components.tsx +2 -2
  7. package/_stack/apps/next-base/src/emails/components/context.tsx +1 -1
  8. package/_stack/apps/next-base/src/emails/components/theme.ts +6 -7
  9. package/_stack/apps/next-base/src/env.ts +2 -3
  10. package/_stack/apps/next-base/src/lib/date.ts +1 -1
  11. package/_stack/apps/next-base/src/server/auth/guards.ts +1 -1
  12. package/_stack/apps/next-base/src/server/better-auth/config.ts +1 -1
  13. package/_stack/apps/next-base/src/server/better-auth/server.ts +2 -2
  14. package/_stack/apps/next-base/src/server/db/schemas/index.ts +1 -1
  15. package/_stack/apps/next-base/src/server/db/seed.ts +2 -2
  16. package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +3 -3
  17. package/_stack/apps/next-base/src/server/email/core/address.ts +3 -3
  18. package/_stack/apps/next-base/src/server/email/core/port.ts +13 -20
  19. package/_stack/apps/next-base/src/server/email/core/render.ts +2 -2
  20. package/_stack/apps/next-base/src/server/email/factory.ts +7 -9
  21. package/_stack/apps/next-base/src/server/email/index.ts +1 -1
  22. package/_stack/apps/next-base/src/trpc/react.tsx +2 -2
  23. package/_stack/apps/next-base/src/trpc/server.ts +1 -1
  24. package/_stack/apps/tanstack-base/.turbo/turbo-typecheck.log +1 -0
  25. package/_stack/apps/tanstack-base/.vscode/settings.json +35 -0
  26. package/_stack/apps/tanstack-base/.zed/settings.json +45 -0
  27. package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +1 -1
  28. package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +1 -1
  29. package/_stack/apps/tanstack-base/src/emails/components/components.tsx +2 -2
  30. package/_stack/apps/tanstack-base/src/emails/components/context.tsx +1 -1
  31. package/_stack/apps/tanstack-base/src/emails/components/theme.ts +6 -7
  32. package/_stack/apps/tanstack-base/src/env.ts +2 -6
  33. package/_stack/apps/tanstack-base/src/lib/date.ts +1 -1
  34. package/_stack/apps/tanstack-base/src/routes/__root.tsx +1 -1
  35. package/_stack/apps/tanstack-base/src/routes/_authed.tsx +1 -4
  36. package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +1 -1
  37. package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +1 -2
  38. package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +1 -1
  39. package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +6 -7
  40. package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +1 -1
  41. package/_stack/apps/tanstack-base/src/server/db/seed.ts +2 -2
  42. package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +3 -3
  43. package/_stack/apps/tanstack-base/src/server/email/core/address.ts +3 -3
  44. package/_stack/apps/tanstack-base/src/server/email/core/port.ts +12 -22
  45. package/_stack/apps/tanstack-base/src/server/email/core/render.ts +1 -1
  46. package/_stack/apps/tanstack-base/src/server/email/factory.ts +7 -8
  47. package/_stack/apps/tanstack-base/src/server/email/index.ts +1 -1
  48. package/_stack/packages/analytics/package.json +26 -0
  49. package/_stack/packages/analytics/src/adapters/noop/index.ts +12 -0
  50. package/_stack/packages/analytics/src/adapters/plausible/config.ts +10 -0
  51. package/_stack/packages/analytics/src/adapters/plausible/index.ts +94 -0
  52. package/_stack/packages/analytics/src/adapters/posthog/config.ts +7 -0
  53. package/_stack/packages/analytics/src/adapters/posthog/index.ts +50 -0
  54. package/_stack/packages/analytics/src/core/port.ts +30 -0
  55. package/_stack/packages/analytics/src/index.ts +17 -0
  56. package/_stack/packages/cache/package.json +25 -0
  57. package/_stack/packages/cache/src/adapters/memory/index.ts +51 -0
  58. package/_stack/packages/cache/src/adapters/redis/config.ts +8 -0
  59. package/_stack/packages/cache/src/adapters/redis/index.ts +73 -0
  60. package/_stack/packages/cache/src/core/port.ts +29 -0
  61. package/_stack/packages/cache/src/core/wrap.ts +20 -0
  62. package/_stack/packages/cache/src/index.ts +12 -0
  63. package/_stack/packages/error-tracking/package.json +25 -0
  64. package/_stack/packages/error-tracking/src/adapters/console/index.ts +43 -0
  65. package/_stack/packages/error-tracking/src/adapters/sentry/config.ts +8 -0
  66. package/_stack/packages/error-tracking/src/adapters/sentry/index.ts +72 -0
  67. package/_stack/packages/error-tracking/src/core/port.ts +39 -0
  68. package/_stack/packages/error-tracking/src/index.ts +14 -0
  69. package/_stack/packages/http/package.json +20 -0
  70. package/_stack/packages/http/src/api.ts +373 -0
  71. package/_stack/packages/http/src/index.ts +14 -0
  72. package/_stack/packages/http/src/responses.ts +25 -0
  73. package/_stack/packages/http/src/types.ts +9 -0
  74. package/_stack/packages/jobs/package.json +27 -0
  75. package/_stack/packages/jobs/src/adapters/inngest/config.ts +8 -0
  76. package/_stack/packages/jobs/src/adapters/inngest/index.ts +93 -0
  77. package/_stack/packages/jobs/src/adapters/memory/index.ts +31 -0
  78. package/_stack/packages/jobs/src/adapters/trigger/config.ts +8 -0
  79. package/_stack/packages/jobs/src/adapters/trigger/index.ts +85 -0
  80. package/_stack/packages/jobs/src/core/port.ts +37 -0
  81. package/_stack/packages/jobs/src/index.ts +23 -0
  82. package/_stack/packages/logger/package.json +25 -0
  83. package/_stack/packages/logger/src/adapters/console/config.ts +7 -0
  84. package/_stack/packages/logger/src/adapters/console/index.ts +69 -0
  85. package/_stack/packages/logger/src/adapters/pino/index.ts +54 -0
  86. package/_stack/packages/logger/src/core/port.ts +21 -0
  87. package/_stack/packages/logger/src/index.ts +12 -0
  88. package/_stack/packages/mailer/src/adapters/brevo/index.ts +3 -3
  89. package/_stack/packages/mailer/src/adapters/resend/index.ts +3 -3
  90. package/_stack/packages/mailer/src/adapters/ses/config.ts +3 -3
  91. package/_stack/packages/mailer/src/adapters/ses/index.ts +4 -5
  92. package/_stack/packages/storage/package.json +27 -0
  93. package/_stack/packages/storage/src/adapters/gcs/config.ts +8 -0
  94. package/_stack/packages/storage/src/adapters/gcs/index.ts +111 -0
  95. package/_stack/packages/storage/src/adapters/local/config.ts +8 -0
  96. package/_stack/packages/storage/src/adapters/local/index.ts +78 -0
  97. package/_stack/packages/storage/src/adapters/r2/config.ts +8 -0
  98. package/_stack/packages/storage/src/adapters/r2/index.ts +39 -0
  99. package/_stack/packages/storage/src/adapters/s3/config.ts +11 -0
  100. package/_stack/packages/storage/src/adapters/s3/index.ts +143 -0
  101. package/_stack/packages/storage/src/core/port.ts +41 -0
  102. package/_stack/packages/storage/src/index.ts +21 -0
  103. package/index.mjs +89 -55
  104. package/lib/build.mjs +21 -11
  105. package/lib/capabilities.mjs +375 -0
  106. package/lib/env.mjs +26 -6
  107. package/lib/foundations.mjs +35 -0
  108. package/lib/identity.mjs +4 -5
  109. package/lib/mailer.mjs +9 -13
  110. package/lib/paths.mjs +15 -0
  111. package/lib/scaffold.mjs +12 -11
  112. package/lib/strip.mjs +9 -24
  113. package/lib/util.mjs +8 -9
  114. package/package.json +1 -1
  115. package/_stack/packages/mailer/capability.json +0 -28
  116. package/_stack/patterns/README.md +0 -58
  117. package/_stack/patterns/_baseline/env.ts +0 -31
  118. package/_stack/patterns/_baseline/tsconfig.json +0 -27
  119. package/_stack/patterns/better-auth/pattern.json +0 -73
  120. package/_stack/patterns/better-auth-next/pattern.json +0 -76
  121. package/_stack/patterns/data-table/pattern.json +0 -43
  122. package/_stack/patterns/drizzle/pattern.json +0 -61
  123. package/_stack/patterns/trpc/pattern.json +0 -61
  124. package/_stack/patterns/trpc-next/pattern.json +0 -64
  125. package/lib/manifests.mjs +0 -61
  126. /package/{_stack/patterns/_baseline → templates}/README-author.md +0 -0
  127. /package/{_stack/patterns/_baseline → templates}/biome.jsonc +0 -0
@@ -0,0 +1,37 @@
1
+ /** Event through the jobs system. `name` routes to subscribed jobs; `data` is the serializable payload. */
2
+ export interface JobEvent<T = unknown> {
3
+ name: string
4
+ data: T
5
+ }
6
+
7
+ /** Background work unit. Subscribes to one `event` name; `handler` runs on a matching event. */
8
+ export interface JobDefinition<T = unknown> {
9
+ id: string
10
+ event: string
11
+ handler: (event: JobEvent<T>) => Promise<void> | void
12
+ }
13
+
14
+ /**
15
+ * The port the app depends on. Swap provider = swap adapter; this never changes.
16
+ *
17
+ * Minimal and event-driven. Rich provider features (steps, concurrency, cron,
18
+ * fan-out) are NOT modeled — use the underlying SDK directly. See the README.
19
+ */
20
+ export interface JobsPort {
21
+ readonly name: string
22
+ /** Register a job. Returns the definition for collection/export. */
23
+ defineJob<T>(def: JobDefinition<T>): JobDefinition<T>
24
+ /** Emit an event, triggering every job subscribed to `event.name`. */
25
+ trigger<T>(event: JobEvent<T>): Promise<void>
26
+ }
27
+
28
+ /** Normalized adapter error so callers never catch provider types. */
29
+ export class JobsError extends Error {
30
+ readonly adapter: string
31
+
32
+ constructor(message: string, options: { adapter: string; cause?: unknown }) {
33
+ super(message, { cause: options.cause })
34
+ this.name = 'JobsError'
35
+ this.adapter = options.adapter
36
+ }
37
+ }
@@ -0,0 +1,23 @@
1
+ export { type InngestConfig, InngestConfigSchema } from './adapters/inngest/config.js'
2
+ export {
3
+ type InngestAdapterOptions,
4
+ type InngestFunction,
5
+ type InngestJobsAdapter,
6
+ type InngestLike,
7
+ type InngestServe,
8
+ inngestAdapter,
9
+ inngestFunctions,
10
+ inngestServeHandler,
11
+ } from './adapters/inngest/index.js'
12
+ export { type MemoryJobsAdapter, memoryAdapter } from './adapters/memory/index.js'
13
+ export { type TriggerDevConfig, TriggerDevConfigSchema } from './adapters/trigger/config.js'
14
+ export {
15
+ type TriggerDevAdapterOptions,
16
+ type TriggerDevJobsAdapter,
17
+ type TriggerDevLike,
18
+ type TriggerTask,
19
+ triggerDevAdapter,
20
+ triggerDevTasks,
21
+ } from './adapters/trigger/index.js'
22
+ export type { JobDefinition, JobEvent, JobsPort } from './core/port.js'
23
+ export { JobsError } from './core/port.js'
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@alfredmouelle/logger",
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
+ "pino": "^10.3.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,7 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const ConsoleConfigSchema = v.object({
4
+ level: v.optional(v.picklist(['trace', 'debug', 'info', 'warn', 'error'] as const), 'info'),
5
+ })
6
+
7
+ export type ConsoleConfig = v.InferOutput<typeof ConsoleConfigSchema>
@@ -0,0 +1,69 @@
1
+ import * as v from 'valibot'
2
+ import type { LogFields, Logger, LogLevel } from '../../core/port.js'
3
+ import { ConsoleConfigSchema } from './config.js'
4
+
5
+ export interface ConsoleAdapterOptions {
6
+ /** Min level to emit; lower-severity dropped. Defaults to `'info'`. */
7
+ level?: LogLevel
8
+ /** Fields pinned onto every line. */
9
+ bindings?: LogFields
10
+ }
11
+
12
+ const ORDER: Record<LogLevel, number> = {
13
+ trace: 10,
14
+ debug: 20,
15
+ info: 30,
16
+ warn: 40,
17
+ error: 50,
18
+ }
19
+
20
+ const SINK: Record<LogLevel, (...args: unknown[]) => void> = {
21
+ // biome-ignore lint/suspicious/noConsole: console adapter
22
+ trace: (...args) => console.debug(...args),
23
+ // biome-ignore lint/suspicious/noConsole: console adapter
24
+ debug: (...args) => console.debug(...args),
25
+ // biome-ignore lint/suspicious/noConsole: console adapter
26
+ info: (...args) => console.info(...args),
27
+ warn: (...args) => console.warn(...args),
28
+ error: (...args) => console.error(...args),
29
+ }
30
+
31
+ function format(level: LogLevel, msg: string, fields: LogFields): string {
32
+ const hasFields = Object.keys(fields).length > 0
33
+ return hasFields ? `[${level}] ${msg} ${JSON.stringify(fields)}` : `[${level}] ${msg}`
34
+ }
35
+
36
+ function build(min: number, bindings: LogFields): Logger {
37
+ const log = (level: LogLevel, msg: string, fields: LogFields = {}) => {
38
+ if (ORDER[level] < min) return
39
+ const merged = { ...bindings, ...fields }
40
+ SINK[level](format(level, msg, merged))
41
+ }
42
+
43
+ return {
44
+ name: 'console',
45
+ trace(msg, fields) {
46
+ log('trace', msg, fields)
47
+ },
48
+ debug(msg, fields) {
49
+ log('debug', msg, fields)
50
+ },
51
+ info(msg, fields) {
52
+ log('info', msg, fields)
53
+ },
54
+ warn(msg, fields) {
55
+ log('warn', msg, fields)
56
+ },
57
+ error(msg, fields) {
58
+ log('error', msg, fields)
59
+ },
60
+ child(childBindings) {
61
+ return build(min, { ...bindings, ...childBindings })
62
+ },
63
+ }
64
+ }
65
+
66
+ export function consoleAdapter(options: ConsoleAdapterOptions = {}): Logger {
67
+ const config = v.parse(ConsoleConfigSchema, { level: options.level })
68
+ return build(ORDER[config.level], options.bindings ?? {})
69
+ }
@@ -0,0 +1,54 @@
1
+ import pino from 'pino'
2
+ import type { LogFields, Logger, LogLevel } from '../../core/port.js'
3
+
4
+ /**
5
+ * Minimal structural view of a pino logger (eases testing).
6
+ * Level methods follow pino's `(mergingObject, message)` convention.
7
+ */
8
+ export interface PinoLike {
9
+ trace(obj: LogFields, msg: string): void
10
+ debug(obj: LogFields, msg: string): void
11
+ info(obj: LogFields, msg: string): void
12
+ warn(obj: LogFields, msg: string): void
13
+ error(obj: LogFields, msg: string): void
14
+ child(bindings: LogFields): PinoLike
15
+ }
16
+
17
+ export interface PinoAdapterOptions {
18
+ /** Min level to emit; defaults to `'info'`. */
19
+ level?: LogLevel
20
+ /** Fields pinned onto every line. */
21
+ bindings?: LogFields
22
+ /** Inject a custom/mock pino; defaults to a real `pino()`. */
23
+ client?: PinoLike
24
+ }
25
+
26
+ function wrap(client: PinoLike): Logger {
27
+ return {
28
+ name: 'pino',
29
+ trace(msg, fields = {}) {
30
+ client.trace(fields, msg)
31
+ },
32
+ debug(msg, fields = {}) {
33
+ client.debug(fields, msg)
34
+ },
35
+ info(msg, fields = {}) {
36
+ client.info(fields, msg)
37
+ },
38
+ warn(msg, fields = {}) {
39
+ client.warn(fields, msg)
40
+ },
41
+ error(msg, fields = {}) {
42
+ client.error(fields, msg)
43
+ },
44
+ child(bindings) {
45
+ return wrap(client.child(bindings))
46
+ },
47
+ }
48
+ }
49
+
50
+ export function pinoAdapter(options: PinoAdapterOptions = {}): Logger {
51
+ const base = options.client ?? (pino({ level: options.level ?? 'info' }) as unknown as PinoLike)
52
+ const client = options.bindings ? base.child(options.bindings) : base
53
+ return wrap(client)
54
+ }
@@ -0,0 +1,21 @@
1
+ /** Severity levels, from most to least verbose. */
2
+ export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'
3
+
4
+ /** Arbitrary structured context attached to a log line. */
5
+ export type LogFields = Record<string, unknown>
6
+
7
+ /**
8
+ * App-facing port; swap adapters at the composition root, never this interface.
9
+ * Each method takes a message + optional fields; `child` merges `bindings` into every line.
10
+ */
11
+ export interface Logger {
12
+ /** Adapter name (`'pino'`, `'console'`, …). */
13
+ readonly name: string
14
+ trace(msg: string, fields?: LogFields): void
15
+ debug(msg: string, fields?: LogFields): void
16
+ info(msg: string, fields?: LogFields): void
17
+ warn(msg: string, fields?: LogFields): void
18
+ error(msg: string, fields?: LogFields): void
19
+ /** Derive a logger pinning `bindings` onto subsequent lines. */
20
+ child(bindings: LogFields): Logger
21
+ }
@@ -0,0 +1,12 @@
1
+ export { type ConsoleConfig, ConsoleConfigSchema } from './adapters/console/config.js'
2
+
3
+ export {
4
+ type ConsoleAdapterOptions,
5
+ consoleAdapter,
6
+ } from './adapters/console/index.js'
7
+ export {
8
+ type PinoAdapterOptions,
9
+ type PinoLike,
10
+ pinoAdapter,
11
+ } from './adapters/pino/index.js'
12
+ export type { LogFields, Logger, LogLevel } from './core/port.js'
@@ -28,7 +28,7 @@ interface BrevoSendResponse {
28
28
  messageIds?: string[]
29
29
  }
30
30
 
31
- /** Minimal structural view of the Brevo client we depend on (eases testing). */
31
+ /** Structural view of the Brevo client (eases testing). */
32
32
  export interface BrevoClientLike {
33
33
  transactionalEmails: {
34
34
  sendTransacEmail(request: BrevoSendRequest): Promise<BrevoSendResponse>
@@ -37,7 +37,7 @@ export interface BrevoClientLike {
37
37
 
38
38
  export interface BrevoAdapterOptions {
39
39
  apiKey: string
40
- /** Inject a custom/mock client. Defaults to a real `BrevoClient`. */
40
+ /** Inject custom/mock client. Defaults to real `BrevoClient`. */
41
41
  client?: BrevoClientLike
42
42
  }
43
43
 
@@ -50,7 +50,7 @@ function toBase64(content: Uint8Array | string): string {
50
50
  }
51
51
 
52
52
  export function brevoAdapter(options: BrevoAdapterOptions): MailerAdapter {
53
- // Validate config early so a missing key fails at construction, not at send().
53
+ // Validate early: missing key fails at construction, not send().
54
54
  const config = v.parse(BrevoConfigSchema, { apiKey: options.apiKey })
55
55
  const client: BrevoClientLike =
56
56
  options.client ?? (new BrevoClient({ apiKey: config.apiKey }) as unknown as BrevoClientLike)
@@ -4,7 +4,7 @@ import { formatAddress } from '../../core/address.js'
4
4
  import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port.js'
5
5
  import { ResendConfigSchema } from './config.js'
6
6
 
7
- /** Minimal structural view of the Resend client we depend on (eases testing). */
7
+ /** Structural view of the Resend client (eases testing). */
8
8
  export interface ResendClient {
9
9
  emails: {
10
10
  send(payload: ResendSendPayload): Promise<{
@@ -30,7 +30,7 @@ interface ResendSendPayload {
30
30
 
31
31
  export interface ResendAdapterOptions {
32
32
  apiKey: string
33
- /** Inject a custom/mock client. Defaults to a real `Resend` instance. */
33
+ /** Inject custom/mock client. Defaults to real `Resend`. */
34
34
  client?: ResendClient
35
35
  }
36
36
 
@@ -39,7 +39,7 @@ function toAttachmentContent(content: Uint8Array | string): Buffer | string {
39
39
  }
40
40
 
41
41
  export function resendAdapter(options: ResendAdapterOptions): MailerAdapter {
42
- // Validate config early so a missing key fails at construction, not at send().
42
+ // Validate early: missing key fails at construction, not send().
43
43
  const config = v.parse(ResendConfigSchema, { apiKey: options.apiKey })
44
44
  const client: ResendClient =
45
45
  options.client ?? (new Resend(config.apiKey) as unknown as ResendClient)
@@ -1,12 +1,12 @@
1
1
  import * as v from 'valibot'
2
2
 
3
3
  export const SesConfigSchema = v.object({
4
- /** AWS region. Falls back to the SDK's resolution (e.g. `AWS_REGION`). */
4
+ /** AWS region. Falls back to SDK resolution (e.g. `AWS_REGION`). */
5
5
  region: v.optional(v.string()),
6
- /** Static credentials. Omit to let the SDK resolve them (env, profile, IAM). */
6
+ /** Static credentials. Omit to let the SDK resolve (env, profile, IAM). */
7
7
  accessKeyId: v.optional(v.string()),
8
8
  secretAccessKey: v.optional(v.string()),
9
- /** SES configuration set to attach (event publishing, dedicated IPs, …). */
9
+ /** SES configuration set (event publishing, dedicated IPs, …). */
10
10
  configurationSetName: v.optional(v.string()),
11
11
  })
12
12
 
@@ -5,7 +5,7 @@ import { formatAddress } from '../../core/address.js'
5
5
  import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port.js'
6
6
  import { SesConfigSchema } from './config.js'
7
7
 
8
- /** Minimal structural view of the SESv2 client we depend on (eases testing). */
8
+ /** Structural view of the SESv2 client (eases testing). */
9
9
  export interface SesClientLike {
10
10
  send(command: { input: SesSendEmailInput }): Promise<{ MessageId?: string }>
11
11
  }
@@ -26,12 +26,12 @@ interface SesSendEmailInput {
26
26
  }
27
27
 
28
28
  export interface SesAdapterOptions {
29
- /** AWS region. Falls back to the SDK's resolution (e.g. `AWS_REGION`). */
29
+ /** AWS region. Falls back to SDK resolution (e.g. `AWS_REGION`). */
30
30
  region?: string
31
31
  accessKeyId?: string
32
32
  secretAccessKey?: string
33
33
  configurationSetName?: string
34
- /** Inject a custom/mock client. Defaults to a real `SESv2Client`. */
34
+ /** Inject custom/mock client. Defaults to real `SESv2Client`. */
35
35
  client?: SesClientLike
36
36
  }
37
37
 
@@ -56,8 +56,7 @@ export function sesAdapter(options: SesAdapterOptions = {}): MailerAdapter {
56
56
  return {
57
57
  name: 'ses',
58
58
  async send(message: RenderedMessage) {
59
- // SES's Simple content can't carry attachments that needs a raw MIME
60
- // message, which this adapter intentionally doesn't build. Fail loudly.
59
+ // SES Simple content can't carry attachments (needs raw MIME, not built here). Fail loudly.
61
60
  if (message.attachments?.length) {
62
61
  throw new MailerError('SES adapter does not support attachments (requires raw MIME)', {
63
62
  adapter: 'ses',
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@alfredmouelle/storage",
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
+ "@aws-sdk/client-s3": "^3.1073.0",
17
+ "@aws-sdk/s3-request-presigner": "^3.1073.0",
18
+ "@google-cloud/storage": "^7.21.0",
19
+ "valibot": "^1.4.1"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.10.2",
23
+ "tsdown": "^0.22.3",
24
+ "typescript": "^5.9.3",
25
+ "vitest": "^4.1.9"
26
+ }
27
+ }
@@ -0,0 +1,8 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const GcsConfigSchema = v.object({
4
+ bucket: v.pipe(v.string(), v.minLength(1, 'GCS_BUCKET is required')),
5
+ projectId: v.optional(v.string()),
6
+ })
7
+
8
+ export type GcsConfig = v.InferOutput<typeof GcsConfigSchema>
@@ -0,0 +1,111 @@
1
+ import { Storage } from '@google-cloud/storage'
2
+ import * as v from 'valibot'
3
+ import {
4
+ type PutOptions,
5
+ type SignedUrlOptions,
6
+ StorageError,
7
+ type StoragePort,
8
+ } from '../../core/port.js'
9
+ import { GcsConfigSchema } from './config.js'
10
+
11
+ /** Structural view of a GCS file handle (eases testing). */
12
+ export interface GcsFileLike {
13
+ save(data: Buffer | string, options?: { contentType?: string }): Promise<void>
14
+ download(): Promise<[Buffer]>
15
+ delete(): Promise<unknown>
16
+ exists(): Promise<[boolean]>
17
+ getSignedUrl(options: {
18
+ version: 'v4'
19
+ action: 'read' | 'write'
20
+ expires: number
21
+ contentType?: string
22
+ }): Promise<[string]>
23
+ }
24
+
25
+ /** Structural view of a GCS bucket handle. */
26
+ export interface GcsBucketLike {
27
+ file(key: string): GcsFileLike
28
+ }
29
+
30
+ /** Structural view of the GCS Storage client. */
31
+ export interface GcsStorageLike {
32
+ bucket(name: string): GcsBucketLike
33
+ }
34
+
35
+ export interface GcsAdapterOptions {
36
+ bucket: string
37
+ projectId?: string
38
+ /** Inject custom/mock client. Defaults to real `Storage`. */
39
+ client?: GcsStorageLike
40
+ }
41
+
42
+ const DEFAULT_EXPIRES_IN = 900
43
+
44
+ function isNotFound(error: unknown): boolean {
45
+ if (typeof error !== 'object' || error === null) return false
46
+ return (error as { code?: number }).code === 404
47
+ }
48
+
49
+ export function gcsAdapter(options: GcsAdapterOptions): StoragePort {
50
+ // Validate early: missing config fails at construction, not use.
51
+ const config = v.parse(GcsConfigSchema, {
52
+ bucket: options.bucket,
53
+ projectId: options.projectId,
54
+ })
55
+
56
+ const client: GcsStorageLike =
57
+ options.client ?? (new Storage({ projectId: config.projectId }) as unknown as GcsStorageLike)
58
+
59
+ const bucket = client.bucket(config.bucket)
60
+
61
+ return {
62
+ name: 'gcs',
63
+ async put(key: string, data: Uint8Array | string, putOptions?: PutOptions) {
64
+ const body = typeof data === 'string' ? data : Buffer.from(data)
65
+ try {
66
+ await bucket.file(key).save(body, { contentType: putOptions?.contentType })
67
+ } catch (cause) {
68
+ throw new StorageError('GCS put failed', { adapter: 'gcs', cause })
69
+ }
70
+ },
71
+ async get(key: string) {
72
+ try {
73
+ const [contents] = await bucket.file(key).download()
74
+ return new Uint8Array(contents)
75
+ } catch (cause) {
76
+ if (isNotFound(cause)) return null
77
+ throw new StorageError('GCS get failed', { adapter: 'gcs', cause })
78
+ }
79
+ },
80
+ async delete(key: string) {
81
+ try {
82
+ await bucket.file(key).delete()
83
+ } catch (cause) {
84
+ if (isNotFound(cause)) return
85
+ throw new StorageError('GCS delete failed', { adapter: 'gcs', cause })
86
+ }
87
+ },
88
+ async exists(key: string) {
89
+ try {
90
+ const [found] = await bucket.file(key).exists()
91
+ return found
92
+ } catch (cause) {
93
+ throw new StorageError('GCS exists failed', { adapter: 'gcs', cause })
94
+ }
95
+ },
96
+ async getSignedUrl(key: string, urlOptions: SignedUrlOptions) {
97
+ const expiresInSeconds = urlOptions.expiresInSeconds ?? DEFAULT_EXPIRES_IN
98
+ try {
99
+ const [url] = await bucket.file(key).getSignedUrl({
100
+ version: 'v4',
101
+ action: urlOptions.operation === 'put' ? 'write' : 'read',
102
+ expires: Date.now() + expiresInSeconds * 1000,
103
+ contentType: urlOptions.contentType,
104
+ })
105
+ return url
106
+ } catch (cause) {
107
+ throw new StorageError('GCS getSignedUrl failed', { adapter: 'gcs', cause })
108
+ }
109
+ },
110
+ }
111
+ }
@@ -0,0 +1,8 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const LocalConfigSchema = v.object({
4
+ baseDir: v.pipe(v.string(), v.minLength(1, 'STORAGE_LOCAL_DIR is required')),
5
+ publicBaseUrl: v.optional(v.string()),
6
+ })
7
+
8
+ export type LocalConfig = v.InferOutput<typeof LocalConfigSchema>
@@ -0,0 +1,78 @@
1
+ import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import * as v from 'valibot'
4
+ import {
5
+ type PutOptions,
6
+ type SignedUrlOptions,
7
+ StorageError,
8
+ type StoragePort,
9
+ } from '../../core/port.js'
10
+ import { LocalConfigSchema } from './config.js'
11
+
12
+ export interface LocalAdapterOptions {
13
+ /** Root directory for stored objects. */
14
+ baseDir: string
15
+ /**
16
+ * Base URL prefixed to keys by {@link StoragePort.getSignedUrl}. NOT signed —
17
+ * returns `${publicBaseUrl}/${key}`. Dev/tests only.
18
+ */
19
+ publicBaseUrl?: string
20
+ }
21
+
22
+ function isNotFound(error: unknown): boolean {
23
+ if (typeof error !== 'object' || error === null) return false
24
+ return (error as { code?: string }).code === 'ENOENT'
25
+ }
26
+
27
+ export function localAdapter(options: LocalAdapterOptions): StoragePort {
28
+ const config = v.parse(LocalConfigSchema, {
29
+ baseDir: options.baseDir,
30
+ publicBaseUrl: options.publicBaseUrl,
31
+ })
32
+
33
+ const resolve = (key: string): string => join(config.baseDir, key)
34
+
35
+ return {
36
+ name: 'local',
37
+ async put(key: string, data: Uint8Array | string, _options?: PutOptions) {
38
+ const path = resolve(key)
39
+ try {
40
+ await mkdir(dirname(path), { recursive: true })
41
+ await writeFile(path, typeof data === 'string' ? data : Buffer.from(data))
42
+ } catch (cause) {
43
+ throw new StorageError('Local put failed', { adapter: 'local', cause })
44
+ }
45
+ },
46
+ async get(key: string) {
47
+ try {
48
+ const buffer = await readFile(resolve(key))
49
+ return new Uint8Array(buffer)
50
+ } catch (cause) {
51
+ if (isNotFound(cause)) return null
52
+ throw new StorageError('Local get failed', { adapter: 'local', cause })
53
+ }
54
+ },
55
+ async delete(key: string) {
56
+ try {
57
+ await unlink(resolve(key))
58
+ } catch (cause) {
59
+ if (isNotFound(cause)) return
60
+ throw new StorageError('Local delete failed', { adapter: 'local', cause })
61
+ }
62
+ },
63
+ async exists(key: string) {
64
+ try {
65
+ await stat(resolve(key))
66
+ return true
67
+ } catch (cause) {
68
+ if (isNotFound(cause)) return false
69
+ throw new StorageError('Local exists failed', { adapter: 'local', cause })
70
+ }
71
+ },
72
+ async getSignedUrl(key: string, _options: SignedUrlOptions) {
73
+ // No real signing; dev/test only.
74
+ const base = config.publicBaseUrl ?? ''
75
+ return `${base}/${key}`
76
+ },
77
+ }
78
+ }
@@ -0,0 +1,8 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const R2ConfigSchema = v.object({
4
+ bucket: v.pipe(v.string(), v.minLength(1, 'R2_BUCKET is required')),
5
+ accountId: v.pipe(v.string(), v.minLength(1, 'R2_ACCOUNT_ID is required')),
6
+ })
7
+
8
+ export type R2Config = v.InferOutput<typeof R2ConfigSchema>
@@ -0,0 +1,39 @@
1
+ import * as v from 'valibot'
2
+ import type { StoragePort } from '../../core/port.js'
3
+ import { type S3ClientLike, type S3Presigner, s3Adapter } from '../s3/index.js'
4
+ import { R2ConfigSchema } from './config.js'
5
+
6
+ /**
7
+ * R2 is S3-compatible: {@link s3Adapter} with R2's endpoint
8
+ * (`https://<accountId>.r2.cloudflarestorage.com`) and `auto` region. All behavior inherited.
9
+ */
10
+ export interface R2AdapterOptions {
11
+ bucket: string
12
+ /** Cloudflare account id (forms the R2 endpoint). */
13
+ accountId: string
14
+ accessKeyId?: string
15
+ secretAccessKey?: string
16
+ /** Inject custom/mock client. Defaults to real `S3Client`. */
17
+ client?: S3ClientLike
18
+ /** Inject custom/mock presigner. */
19
+ presign?: S3Presigner
20
+ }
21
+
22
+ export function r2Adapter(options: R2AdapterOptions): StoragePort {
23
+ const config = v.parse(R2ConfigSchema, {
24
+ bucket: options.bucket,
25
+ accountId: options.accountId,
26
+ })
27
+
28
+ const s3 = s3Adapter({
29
+ bucket: config.bucket,
30
+ region: 'auto',
31
+ endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
32
+ accessKeyId: options.accessKeyId,
33
+ secretAccessKey: options.secretAccessKey,
34
+ client: options.client,
35
+ presign: options.presign,
36
+ })
37
+
38
+ return { ...s3, name: 'r2' }
39
+ }
@@ -0,0 +1,11 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const S3ConfigSchema = v.object({
4
+ bucket: v.pipe(v.string(), v.minLength(1, 'S3_BUCKET is required')),
5
+ region: v.pipe(v.string(), v.minLength(1, 'S3_REGION is required')),
6
+ accessKeyId: v.optional(v.string()),
7
+ secretAccessKey: v.optional(v.string()),
8
+ endpoint: v.optional(v.string()),
9
+ })
10
+
11
+ export type S3Config = v.InferOutput<typeof S3ConfigSchema>