costhawk 1.0.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 (171) hide show
  1. package/.claude/settings.local.json +32 -0
  2. package/STRATEGIC_PLAN_2025-12-01.md +934 -0
  3. package/costcanary/.claude/settings.local.json +9 -0
  4. package/costcanary/.env.production.template +38 -0
  5. package/costcanary/.eslintrc.json +22 -0
  6. package/costcanary/.nvmrc +1 -0
  7. package/costcanary/.prettierignore +11 -0
  8. package/costcanary/.prettierrc.json +12 -0
  9. package/costcanary/ADMIN_SETUP.md +68 -0
  10. package/costcanary/CLAUDE.md +228 -0
  11. package/costcanary/CLERK_SETUP.md +69 -0
  12. package/costcanary/DATABASE_SETUP.md +136 -0
  13. package/costcanary/DEMO_CHECKLIST.md +62 -0
  14. package/costcanary/DEPLOYMENT.md +31 -0
  15. package/costcanary/PRODUCTION_RECOVERY.md +109 -0
  16. package/costcanary/README.md +247 -0
  17. package/costcanary/STRIPE_SECURITY_AUDIT.md +123 -0
  18. package/costcanary/TESTING_ADMIN.md +92 -0
  19. package/costcanary/app/(auth)/sign-in/[[...sign-in]]/page.tsx +25 -0
  20. package/costcanary/app/(auth)/sign-up/[[...sign-up]]/page.tsx +25 -0
  21. package/costcanary/app/(dashboard)/dashboard/admin/page.tsx +260 -0
  22. package/costcanary/app/(dashboard)/dashboard/alerts/page.tsx +64 -0
  23. package/costcanary/app/(dashboard)/dashboard/api-keys/page.tsx +231 -0
  24. package/costcanary/app/(dashboard)/dashboard/billing/page.tsx +349 -0
  25. package/costcanary/app/(dashboard)/dashboard/layout.tsx +188 -0
  26. package/costcanary/app/(dashboard)/dashboard/page.tsx +13 -0
  27. package/costcanary/app/(dashboard)/dashboard/playground/page.tsx +605 -0
  28. package/costcanary/app/(dashboard)/dashboard/settings/page.tsx +86 -0
  29. package/costcanary/app/(dashboard)/dashboard/usage/page.tsx +354 -0
  30. package/costcanary/app/(dashboard)/dashboard/wrapped-keys/page.tsx +677 -0
  31. package/costcanary/app/(marketing)/page.tsx +90 -0
  32. package/costcanary/app/(marketing)/pricing/page.tsx +272 -0
  33. package/costcanary/app/admin/pricing-status/page.tsx +338 -0
  34. package/costcanary/app/api/admin/check-pricing/route.ts +127 -0
  35. package/costcanary/app/api/admin/debug/route.ts +44 -0
  36. package/costcanary/app/api/admin/fix-pricing/route.ts +216 -0
  37. package/costcanary/app/api/admin/pricing-jobs/[jobId]/route.ts +48 -0
  38. package/costcanary/app/api/admin/pricing-jobs/route.ts +45 -0
  39. package/costcanary/app/api/admin/trigger-pricing/route.ts +209 -0
  40. package/costcanary/app/api/admin/whoami/route.ts +44 -0
  41. package/costcanary/app/api/auth/clerk/[...nextjs]/route.ts +93 -0
  42. package/costcanary/app/api/debug/wrapped-key/route.ts +51 -0
  43. package/costcanary/app/api/debug-status/route.ts +9 -0
  44. package/costcanary/app/api/debug-version/route.ts +12 -0
  45. package/costcanary/app/api/health/route.ts +14 -0
  46. package/costcanary/app/api/health-simple/route.ts +18 -0
  47. package/costcanary/app/api/keys/route.ts +162 -0
  48. package/costcanary/app/api/keys/wrapped/[id]/revoke/route.ts +86 -0
  49. package/costcanary/app/api/keys/wrapped/[id]/rotate/route.ts +81 -0
  50. package/costcanary/app/api/keys/wrapped/route.ts +241 -0
  51. package/costcanary/app/api/optimizer/preview/route.ts +147 -0
  52. package/costcanary/app/api/optimizer/route.ts +118 -0
  53. package/costcanary/app/api/pricing/models/route.ts +102 -0
  54. package/costcanary/app/api/proxy/[...path]/route.ts +391 -0
  55. package/costcanary/app/api/proxy/anthropic/route.ts +539 -0
  56. package/costcanary/app/api/proxy/google/route.ts +395 -0
  57. package/costcanary/app/api/proxy/openai/route.ts +529 -0
  58. package/costcanary/app/api/simple-test/route.ts +7 -0
  59. package/costcanary/app/api/stripe/checkout/route.ts +201 -0
  60. package/costcanary/app/api/stripe/webhook/route.ts +392 -0
  61. package/costcanary/app/api/test-connection/route.ts +209 -0
  62. package/costcanary/app/api/test-proxy/route.ts +7 -0
  63. package/costcanary/app/api/test-simple/route.ts +20 -0
  64. package/costcanary/app/api/usage/current/route.ts +112 -0
  65. package/costcanary/app/api/usage/stats/route.ts +129 -0
  66. package/costcanary/app/api/usage/stream/route.ts +113 -0
  67. package/costcanary/app/api/usage/summary/route.ts +67 -0
  68. package/costcanary/app/api/usage/trend/route.ts +119 -0
  69. package/costcanary/app/api/ws/route.ts +23 -0
  70. package/costcanary/app/globals.css +280 -0
  71. package/costcanary/app/layout.tsx +87 -0
  72. package/costcanary/components/Header.tsx +85 -0
  73. package/costcanary/components/dashboard/AddApiKeyModal.tsx +264 -0
  74. package/costcanary/components/dashboard/dashboard-content.tsx +329 -0
  75. package/costcanary/components/landing/DashboardPreview.tsx +222 -0
  76. package/costcanary/components/landing/Features.tsx +238 -0
  77. package/costcanary/components/landing/Footer.tsx +83 -0
  78. package/costcanary/components/landing/Hero.tsx +193 -0
  79. package/costcanary/components/landing/Pricing.tsx +250 -0
  80. package/costcanary/components/landing/Testimonials.tsx +248 -0
  81. package/costcanary/components/theme-provider.tsx +8 -0
  82. package/costcanary/components/ui/alert.tsx +59 -0
  83. package/costcanary/components/ui/badge.tsx +36 -0
  84. package/costcanary/components/ui/button.tsx +56 -0
  85. package/costcanary/components/ui/card.tsx +79 -0
  86. package/costcanary/components/ui/dialog.tsx +122 -0
  87. package/costcanary/components/ui/input.tsx +22 -0
  88. package/costcanary/components/ui/label.tsx +26 -0
  89. package/costcanary/components/ui/progress.tsx +28 -0
  90. package/costcanary/components/ui/select.tsx +160 -0
  91. package/costcanary/components/ui/separator.tsx +31 -0
  92. package/costcanary/components/ui/switch.tsx +29 -0
  93. package/costcanary/components/ui/tabs.tsx +55 -0
  94. package/costcanary/components/ui/toast.tsx +127 -0
  95. package/costcanary/components/ui/toaster.tsx +35 -0
  96. package/costcanary/components/ui/use-toast.ts +189 -0
  97. package/costcanary/components.json +17 -0
  98. package/costcanary/debug-wrapped-keys.md +117 -0
  99. package/costcanary/fix-console.sh +30 -0
  100. package/costcanary/lib/admin-auth.ts +226 -0
  101. package/costcanary/lib/admin-security.ts +124 -0
  102. package/costcanary/lib/audit-events.ts +62 -0
  103. package/costcanary/lib/audit.ts +158 -0
  104. package/costcanary/lib/chart-colors.ts +152 -0
  105. package/costcanary/lib/cost-calculator.ts +212 -0
  106. package/costcanary/lib/db-utils.ts +325 -0
  107. package/costcanary/lib/db.ts +14 -0
  108. package/costcanary/lib/encryption.ts +120 -0
  109. package/costcanary/lib/kms.ts +358 -0
  110. package/costcanary/lib/model-alias.ts +180 -0
  111. package/costcanary/lib/pricing.ts +292 -0
  112. package/costcanary/lib/prisma.ts +52 -0
  113. package/costcanary/lib/railway-db.ts +157 -0
  114. package/costcanary/lib/sse-parser.ts +283 -0
  115. package/costcanary/lib/stripe-client.ts +81 -0
  116. package/costcanary/lib/stripe-server.ts +52 -0
  117. package/costcanary/lib/tokens.ts +396 -0
  118. package/costcanary/lib/usage-limits.ts +164 -0
  119. package/costcanary/lib/utils.ts +6 -0
  120. package/costcanary/lib/websocket.ts +153 -0
  121. package/costcanary/lib/wrapped-keys.ts +531 -0
  122. package/costcanary/market-research.md +443 -0
  123. package/costcanary/middleware.ts +48 -0
  124. package/costcanary/next.config.js +43 -0
  125. package/costcanary/nia-sources.md +151 -0
  126. package/costcanary/package-lock.json +12162 -0
  127. package/costcanary/package.json +92 -0
  128. package/costcanary/package.json.backup +89 -0
  129. package/costcanary/postcss.config.js +6 -0
  130. package/costcanary/pricing-worker/.env.example +8 -0
  131. package/costcanary/pricing-worker/README.md +81 -0
  132. package/costcanary/pricing-worker/package-lock.json +1109 -0
  133. package/costcanary/pricing-worker/package.json +26 -0
  134. package/costcanary/pricing-worker/railway.json +13 -0
  135. package/costcanary/pricing-worker/schema.prisma +326 -0
  136. package/costcanary/pricing-worker/src/index.ts +115 -0
  137. package/costcanary/pricing-worker/src/services/pricing-updater.ts +79 -0
  138. package/costcanary/pricing-worker/src/services/tavily-client.ts +474 -0
  139. package/costcanary/pricing-worker/test-tavily.ts +47 -0
  140. package/costcanary/pricing-worker/tsconfig.json +24 -0
  141. package/costcanary/prisma/migrations/001_add_stripe_fields.sql +26 -0
  142. package/costcanary/prisma/schema.prisma +326 -0
  143. package/costcanary/prisma/seed-pricing.ts +133 -0
  144. package/costcanary/public/costhawk-logo.png +0 -0
  145. package/costcanary/railway.json +30 -0
  146. package/costcanary/railway.toml +16 -0
  147. package/costcanary/research-nia.md +298 -0
  148. package/costcanary/research.md +411 -0
  149. package/costcanary/scripts/build-production.js +65 -0
  150. package/costcanary/scripts/check-current-pricing.ts +51 -0
  151. package/costcanary/scripts/check-pricing-data.ts +174 -0
  152. package/costcanary/scripts/create-stripe-prices.js +49 -0
  153. package/costcanary/scripts/fix-pricing-data.ts +135 -0
  154. package/costcanary/scripts/fix-pricing-db.ts +148 -0
  155. package/costcanary/scripts/postinstall.js +58 -0
  156. package/costcanary/scripts/railway-deploy.sh +52 -0
  157. package/costcanary/scripts/run-migration.js +61 -0
  158. package/costcanary/scripts/start-production.js +175 -0
  159. package/costcanary/scripts/test-wrapped-key.ts +85 -0
  160. package/costcanary/scripts/validate-deployment.js +176 -0
  161. package/costcanary/scripts/validate-production.js +119 -0
  162. package/costcanary/server.js.backup +38 -0
  163. package/costcanary/tailwind.config.ts +216 -0
  164. package/costcanary/test-pricing-status.sh +27 -0
  165. package/costcanary/tsconfig.json +42 -0
  166. package/docs/sessions/session-2025-12-01.md +570 -0
  167. package/executive-summary.md +302 -0
  168. package/index.js +1 -0
  169. package/nia-sources.md +163 -0
  170. package/package.json +16 -0
  171. package/research.md +750 -0
@@ -0,0 +1,292 @@
1
+ import { prisma } from './prisma';
2
+ import { Provider } from '@prisma/client';
3
+
4
+ // Initialize Redis if available (optional dependency)
5
+ let redis: { get: (key: string) => Promise<string | null>; setex: (key: string, ttl: number, value: string) => Promise<void>; del: (...keys: string[]) => Promise<void>; keys: (pattern: string) => Promise<string[]> } | null = null;
6
+ try {
7
+ if (process.env.REDIS_URL) {
8
+ const Redis = require('ioredis');
9
+ redis = new Redis(process.env.REDIS_URL);
10
+ }
11
+ } catch (error) {
12
+ // DEBUG: console.log('[pricing] Redis not available, using database only');
13
+ }
14
+ const CACHE_TTL = 60 * 10; // 10 minutes
15
+
16
+ export interface PriceRow {
17
+ provider: Provider;
18
+ canonicalModel: string;
19
+ versionId: string;
20
+ unitInputPer1k: number;
21
+ unitOutputPer1k: number;
22
+ }
23
+
24
+ export interface UsageData {
25
+ input?: number;
26
+ output?: number;
27
+ prompt?: number;
28
+ completion?: number;
29
+ total?: number;
30
+ }
31
+
32
+ export interface CostBreakdown {
33
+ input: number;
34
+ output: number;
35
+ costIn: number;
36
+ costOut: number;
37
+ total: number;
38
+ }
39
+
40
+ /**
41
+ * Get the active pricing for a specific provider and model
42
+ * Uses Redis caching if available
43
+ */
44
+ export async function getActivePrice(
45
+ provider: Provider,
46
+ canonicalModel: string
47
+ ): Promise<PriceRow | null> {
48
+ if (!canonicalModel) return null;
49
+
50
+ const cacheKey = `price:${provider}:${canonicalModel}`;
51
+
52
+ // Try Redis cache first
53
+ if (redis) {
54
+ try {
55
+ const cached = await redis.get(cacheKey);
56
+ if (cached) {
57
+ // DEBUG: console.log(`[pricing] Cache hit for ${provider}:${canonicalModel}`);
58
+ try {
59
+ return JSON.parse(cached);
60
+ } catch (parseError) {
61
+ // DEBUG: console.error('[pricing] Failed to parse cached value:', parseError);
62
+ // Continue to database query
63
+ }
64
+ }
65
+ } catch (error) {
66
+ // DEBUG: console.error('[pricing] Redis error:', error);
67
+ }
68
+ }
69
+
70
+ // Query database for active pricing
71
+ try {
72
+ const version = await prisma.pricingVersion.findFirst({
73
+ where: {
74
+ provider,
75
+ isActive: true
76
+ },
77
+ orderBy: { createdAt: 'desc' },
78
+ include: {
79
+ models: {
80
+ where: {
81
+ provider,
82
+ canonicalModel
83
+ },
84
+ take: 1
85
+ },
86
+ },
87
+ });
88
+
89
+ const priceModel = version?.models[0];
90
+ if (!priceModel) {
91
+ // DEBUG: console.warn(`[pricing] No pricing found for ${provider}:${canonicalModel}`);
92
+ return null;
93
+ }
94
+
95
+ const priceRow: PriceRow = {
96
+ provider,
97
+ canonicalModel,
98
+ versionId: version.id,
99
+ unitInputPer1k: Number(priceModel.unitInputPer1k),
100
+ unitOutputPer1k: Number(priceModel.unitOutputPer1k),
101
+ };
102
+
103
+ // Cache in Redis
104
+ if (redis) {
105
+ try {
106
+ await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(priceRow));
107
+ // DEBUG: console.log(`[pricing] Cached ${provider}:${canonicalModel}`);
108
+ } catch (error) {
109
+ // DEBUG: console.error('[pricing] Redis cache error:', error);
110
+ }
111
+ }
112
+
113
+ return priceRow;
114
+ } catch (error) {
115
+ // DEBUG: console.error('[pricing] Database error:', error);
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Compute cost based on token usage and pricing
122
+ */
123
+ export function computeCost(
124
+ usage: UsageData,
125
+ price: PriceRow
126
+ ): CostBreakdown {
127
+ // Normalize token counts (handle OpenAI, Anthropic, and common formats)
128
+ // Anthropic uses input_tokens/output_tokens, OpenAI uses prompt_tokens/completion_tokens
129
+ // We also accept input/output for normalized formats
130
+ const usageAny = usage as Record<string, unknown>;
131
+ const inputTokens =
132
+ (typeof usageAny.input_tokens === 'number' ? usageAny.input_tokens : 0) ||
133
+ (typeof usage.input === 'number' ? usage.input : 0) ||
134
+ (typeof usage.prompt === 'number' ? usage.prompt : 0) ||
135
+ (typeof usageAny.prompt_tokens === 'number' ? usageAny.prompt_tokens : 0) ||
136
+ 0;
137
+ const outputTokens =
138
+ (typeof usageAny.output_tokens === 'number' ? usageAny.output_tokens : 0) ||
139
+ (typeof usage.output === 'number' ? usage.output : 0) ||
140
+ (typeof usage.completion === 'number' ? usage.completion : 0) ||
141
+ (typeof usageAny.completion_tokens === 'number' ? usageAny.completion_tokens : 0) ||
142
+ 0;
143
+
144
+ // Calculate costs (prices are per 1000 tokens)
145
+ const costIn = (inputTokens / 1000) * price.unitInputPer1k;
146
+ const costOut = (outputTokens / 1000) * price.unitOutputPer1k;
147
+ const total = +(costIn + costOut).toFixed(6);
148
+
149
+ return {
150
+ input: inputTokens,
151
+ output: outputTokens,
152
+ costIn: +costIn.toFixed(6),
153
+ costOut: +costOut.toFixed(6),
154
+ total,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Clear the pricing cache (useful after updating prices)
160
+ */
161
+ export async function clearPricingCache(
162
+ provider?: Provider,
163
+ model?: string
164
+ ): Promise<void> {
165
+ if (!redis) return;
166
+
167
+ try {
168
+ if (provider && model) {
169
+ // Clear specific model
170
+ await redis.del(`price:${provider}:${model}`);
171
+ // DEBUG: console.log(`[pricing] Cleared cache for ${provider}:${model}`);
172
+ } else if (provider) {
173
+ // Clear all models for provider
174
+ const keys = await redis.keys(`price:${provider}:*`);
175
+ if (keys.length > 0) {
176
+ await redis.del(...keys);
177
+ // DEBUG: console.log(`[pricing] Cleared ${keys.length} cache entries for ${provider}`);
178
+ }
179
+ } else {
180
+ // Clear all pricing cache
181
+ const keys = await redis.keys('price:*');
182
+ if (keys.length > 0) {
183
+ await redis.del(...keys);
184
+ // DEBUG: console.log(`[pricing] Cleared all ${keys.length} pricing cache entries`);
185
+ }
186
+ }
187
+ } catch (error) {
188
+ // DEBUG: console.error('[pricing] Error clearing cache:', error);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Get fallback pricing for a model (hardcoded as backup)
194
+ * This ensures the system works even if database pricing isn't set up
195
+ *
196
+ * Prices are per 1K tokens (divide by 1000 from per-1M pricing)
197
+ * Last updated: December 2025
198
+ */
199
+ export function getFallbackPrice(
200
+ provider: Provider,
201
+ model: string
202
+ ): PriceRow | null {
203
+ const fallbackPrices: Partial<Record<Provider, Record<string, { input: number; output: number }>>> = {
204
+ [Provider.OPENAI]: {
205
+ // GPT-5 (December 2025) - $1.25/$10 per 1M
206
+ 'gpt-5': { input: 0.00125, output: 0.01 },
207
+ 'gpt-5-mini': { input: 0.00025, output: 0.002 },
208
+ // GPT-4o series (still available)
209
+ 'gpt-4o': { input: 0.0025, output: 0.01 },
210
+ 'gpt-4o-mini': { input: 0.00015, output: 0.0006 },
211
+ 'gpt-4o-audio-preview': { input: 0.0025, output: 0.01 },
212
+ // o1/o3 reasoning models
213
+ 'o1': { input: 0.015, output: 0.06 },
214
+ 'o1-mini': { input: 0.0011, output: 0.0044 },
215
+ 'o1-preview': { input: 0.015, output: 0.06 },
216
+ 'o3-mini': { input: 0.0011, output: 0.0044 },
217
+ // Legacy models
218
+ 'gpt-4-turbo': { input: 0.01, output: 0.03 },
219
+ 'gpt-4': { input: 0.03, output: 0.06 },
220
+ 'gpt-3.5-turbo': { input: 0.0005, output: 0.0015 },
221
+ },
222
+ [Provider.ANTHROPIC]: {
223
+ // Claude 4.5 (December 2025) - correct API model IDs
224
+ 'claude-opus-4-5': { input: 0.005, output: 0.025 },
225
+ 'claude-opus-4-5-20251101': { input: 0.005, output: 0.025 },
226
+ 'claude-sonnet-4-5': { input: 0.003, output: 0.015 },
227
+ 'claude-sonnet-4-5-20250929': { input: 0.003, output: 0.015 },
228
+ // Claude 3.5 - widely used
229
+ 'claude-3-5-sonnet-20241022': { input: 0.003, output: 0.015 },
230
+ 'claude-3-5-sonnet-20240620': { input: 0.003, output: 0.015 },
231
+ 'claude-3-5-sonnet-latest': { input: 0.003, output: 0.015 },
232
+ 'claude-3-5-haiku-20241022': { input: 0.0008, output: 0.004 },
233
+ 'claude-3-5-haiku-latest': { input: 0.0008, output: 0.004 },
234
+ // Legacy Claude 3
235
+ 'claude-3-opus-20240229': { input: 0.015, output: 0.075 },
236
+ 'claude-3-opus-latest': { input: 0.015, output: 0.075 },
237
+ 'claude-3-sonnet-20240229': { input: 0.003, output: 0.015 },
238
+ 'claude-3-haiku-20240307': { input: 0.00025, output: 0.00125 },
239
+ },
240
+ [Provider.GOOGLE]: {
241
+ // Gemini 2.0 (December 2025)
242
+ 'gemini-2.0-flash': { input: 0.0001, output: 0.0004 },
243
+ 'gemini-2.0-flash-exp': { input: 0.0001, output: 0.0004 },
244
+ 'gemini-2.0-pro': { input: 0.00035, output: 0.0015 },
245
+ // Gemini 1.5 (still available)
246
+ 'gemini-1.5-pro': { input: 0.00125, output: 0.005 },
247
+ 'gemini-1.5-pro-latest': { input: 0.00125, output: 0.005 },
248
+ 'gemini-1.5-flash': { input: 0.0006, output: 0.0024 },
249
+ 'gemini-1.5-flash-latest': { input: 0.0006, output: 0.0024 },
250
+ 'gemini-1.5-flash-8b': { input: 0.0000375, output: 0.00015 },
251
+ // Legacy
252
+ 'gemini-pro': { input: 0.0005, output: 0.0015 },
253
+ },
254
+ };
255
+
256
+ const providerPrices = fallbackPrices[provider];
257
+ if (!providerPrices) return null;
258
+
259
+ // Try exact match first
260
+ let pricing = providerPrices[model];
261
+
262
+ // If not found, try partial match
263
+ if (!pricing) {
264
+ const modelKey = Object.keys(providerPrices).find(key =>
265
+ model.toLowerCase().includes(key.toLowerCase()) ||
266
+ key.toLowerCase().includes(model.toLowerCase())
267
+ );
268
+ if (modelKey) {
269
+ pricing = providerPrices[modelKey];
270
+ }
271
+ }
272
+
273
+ if (!pricing) {
274
+ // Use first available as default
275
+ const values = Object.values(providerPrices);
276
+ if (values.length > 0) {
277
+ pricing = values[0];
278
+ }
279
+ }
280
+
281
+ if (!pricing) {
282
+ return null;
283
+ }
284
+
285
+ return {
286
+ provider,
287
+ canonicalModel: model,
288
+ versionId: 'fallback',
289
+ unitInputPer1k: pricing.input,
290
+ unitOutputPer1k: pricing.output,
291
+ };
292
+ }
@@ -0,0 +1,52 @@
1
+ import { PrismaClient } from '@prisma/client'
2
+
3
+ declare global {
4
+ // eslint-disable-next-line no-var
5
+ var prisma: PrismaClient | undefined
6
+ }
7
+
8
+ // PrismaClient is attached to the `global` object in development to prevent
9
+ // exhausting your database connection limit.
10
+ //
11
+ // Learn more:
12
+ // https://pris.ly/d/help/next-js-best-practices
13
+
14
+ const prismaClientSingleton = () => {
15
+ // Railway-specific: Use public URL if internal fails
16
+ const databaseUrl = process.env.DATABASE_PUBLIC_URL || process.env.DATABASE_URL;
17
+
18
+ if (!databaseUrl) {
19
+ console.error('[Prisma] No database URL configured!');
20
+ console.error('[Prisma] DATABASE_URL:', !!process.env.DATABASE_URL);
21
+ console.error('[Prisma] DATABASE_PUBLIC_URL:', !!process.env.DATABASE_PUBLIC_URL);
22
+ }
23
+
24
+ return new PrismaClient({
25
+ datasources: {
26
+ db: {
27
+ url: databaseUrl || 'postgresql://placeholder:placeholder@localhost:5432/placeholder'
28
+ }
29
+ },
30
+ log: process.env.NODE_ENV === 'development'
31
+ ? ['query', 'error', 'warn']
32
+ : ['error'],
33
+ })
34
+ }
35
+
36
+ export const prisma = globalThis.prisma ?? prismaClientSingleton()
37
+
38
+ if (process.env.NODE_ENV !== 'production') globalThis.prisma = prisma
39
+
40
+ // Export types for use in the application
41
+ export type {
42
+ User,
43
+ ApiKey,
44
+ UsageLog,
45
+ Budget,
46
+ Alert,
47
+ AuditLog,
48
+ Provider,
49
+ Period,
50
+ AlertType,
51
+ AuditAction
52
+ } from '@prisma/client'
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Railway-Specific Database Connection Utilities
3
+ *
4
+ * Handles Railway's internal DNS resolution timing issues
5
+ * and provides fallback strategies for reliable database connectivity.
6
+ */
7
+
8
+ import { PrismaClient } from '@prisma/client';
9
+
10
+ interface DatabaseConnectionResult {
11
+ success: boolean;
12
+ client?: PrismaClient;
13
+ error?: string;
14
+ urlUsed?: string;
15
+ }
16
+
17
+ /**
18
+ * Creates a database connection with Railway-specific fallback logic
19
+ */
20
+ export async function createRailwayDatabaseConnection(
21
+ retries = 3,
22
+ timeoutMs = 5000
23
+ ): Promise<DatabaseConnectionResult> {
24
+ const connectionStrategies = [
25
+ {
26
+ name: 'internal',
27
+ url: process.env.DATABASE_URL,
28
+ description: 'Railway internal URL'
29
+ },
30
+ {
31
+ name: 'public',
32
+ url: process.env.DATABASE_PUBLIC_URL,
33
+ description: 'Railway public URL'
34
+ }
35
+ ];
36
+
37
+ for (const strategy of connectionStrategies) {
38
+ if (!strategy.url) {
39
+ continue;
40
+ }
41
+
42
+ for (let attempt = 1; attempt <= retries; attempt++) {
43
+ try {
44
+ // Railway deployment logging
45
+ // eslint-disable-next-line no-console
46
+ console.log(`[Railway DB] Attempting ${strategy.description} (attempt ${attempt}/${retries})`)
47
+
48
+ const client = new PrismaClient({
49
+ datasources: {
50
+ db: { url: strategy.url }
51
+ },
52
+ log: ['error']
53
+ });
54
+
55
+ // Test connection with timeout
56
+ const connectionTest = client.$queryRaw`SELECT 1 as test`;
57
+ const timeoutPromise = new Promise((_, reject) =>
58
+ setTimeout(() => reject(new Error('Connection timeout')), timeoutMs)
59
+ );
60
+
61
+ await Promise.race([connectionTest, timeoutPromise]);
62
+
63
+ // eslint-disable-next-line no-console
64
+ console.log(`[Railway DB] ✅ Connected using ${strategy.description}`);
65
+ return {
66
+ success: true,
67
+ client,
68
+ urlUsed: strategy.name
69
+ };
70
+
71
+ } catch (error) {
72
+ // eslint-disable-next-line no-console
73
+ console.log(`[Railway DB] ❌ ${strategy.description} attempt ${attempt} failed:`,
74
+ error instanceof Error ? error.message : 'Unknown error'
75
+ );
76
+
77
+ if (attempt === retries) {
78
+ // eslint-disable-next-line no-console
79
+ console.log(`[Railway DB] All attempts failed for ${strategy.description}`);
80
+ } else {
81
+ // Wait before retry
82
+ await new Promise(resolve => setTimeout(resolve, 1000));
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ return {
89
+ success: false,
90
+ error: 'All database connection strategies failed'
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Tests database connectivity with multiple strategies
96
+ */
97
+ export async function testDatabaseConnectivity(): Promise<{
98
+ healthy: boolean;
99
+ details: {
100
+ internal?: { success: boolean; latency?: number; error?: string };
101
+ public?: { success: boolean; latency?: number; error?: string };
102
+ };
103
+ }> {
104
+ const details: {
105
+ internal?: { success: boolean; latency?: number; error?: string };
106
+ public?: { success: boolean; latency?: number; error?: string };
107
+ } = {};
108
+
109
+ const strategies = [
110
+ { name: 'internal' as const, url: process.env.DATABASE_URL },
111
+ { name: 'public' as const, url: process.env.DATABASE_PUBLIC_URL }
112
+ ];
113
+
114
+ let anySuccessful = false;
115
+
116
+ for (const strategy of strategies) {
117
+ if (!strategy.url) {
118
+ details[strategy.name] = { success: false, error: 'URL not configured' };
119
+ continue;
120
+ }
121
+
122
+ try {
123
+ const startTime = Date.now();
124
+
125
+ const client = new PrismaClient({
126
+ datasources: { db: { url: strategy.url } },
127
+ log: []
128
+ });
129
+
130
+ const timeoutPromise = new Promise((_, reject) =>
131
+ setTimeout(() => reject(new Error('Timeout')), 3000)
132
+ );
133
+
134
+ await Promise.race([
135
+ client.$queryRaw`SELECT 1 as test`,
136
+ timeoutPromise
137
+ ]);
138
+
139
+ await client.$disconnect();
140
+
141
+ const latency = Date.now() - startTime;
142
+ details[strategy.name] = { success: true, latency };
143
+ anySuccessful = true;
144
+
145
+ } catch (error) {
146
+ details[strategy.name] = {
147
+ success: false,
148
+ error: error instanceof Error ? error.message : 'Unknown error'
149
+ };
150
+ }
151
+ }
152
+
153
+ return {
154
+ healthy: anySuccessful,
155
+ details
156
+ };
157
+ }