@geenius/adapters 0.1.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/.changeset/config.json +11 -0
- package/.github/CODEOWNERS +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +16 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +11 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +10 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/ci.yml +23 -0
- package/.github/workflows/release.yml +29 -0
- package/.nvmrc +1 -0
- package/.project/ACCOUNT.yaml +4 -0
- package/.project/IDEAS.yaml +7 -0
- package/.project/PROJECT.yaml +11 -0
- package/.project/ROADMAP.yaml +15 -0
- package/CHANGELOG.md +11 -0
- package/CODE_OF_CONDUCT.md +16 -0
- package/CONTRIBUTING.md +26 -0
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/SECURITY.md +15 -0
- package/SUPPORT.md +8 -0
- package/package.json +51 -0
- package/packages/convex/README.md +64 -0
- package/packages/convex/package.json +42 -0
- package/packages/convex/src/adapter.ts +39 -0
- package/packages/convex/src/index.ts +19 -0
- package/packages/convex/src/mutations.ts +142 -0
- package/packages/convex/src/queries.ts +106 -0
- package/packages/convex/src/schema.ts +54 -0
- package/packages/convex/src/types.ts +20 -0
- package/packages/convex/tsconfig.json +11 -0
- package/packages/convex/tsup.config.ts +10 -0
- package/packages/react/README.md +1 -0
- package/packages/react/package.json +45 -0
- package/packages/react/src/components/AdapterCard.tsx +49 -0
- package/packages/react/src/components/AdapterConfigForm.tsx +118 -0
- package/packages/react/src/components/AdapterList.tsx +84 -0
- package/packages/react/src/components/AdapterStatusBadge.tsx +30 -0
- package/packages/react/src/components/index.ts +4 -0
- package/packages/react/src/hooks/index.ts +75 -0
- package/packages/react/src/index.tsx +44 -0
- package/packages/react/src/pages/AdapterDetailPage.tsx +133 -0
- package/packages/react/src/pages/AdaptersPage.tsx +111 -0
- package/packages/react/src/pages/index.ts +2 -0
- package/packages/react/src/provider/AdapterProvider.tsx +115 -0
- package/packages/react/src/provider/index.ts +2 -0
- package/packages/react/tsconfig.json +18 -0
- package/packages/react/tsup.config.ts +10 -0
- package/packages/react-css/README.md +1 -0
- package/packages/react-css/package.json +44 -0
- package/packages/react-css/src/adapters.css +1576 -0
- package/packages/react-css/src/components/AdapterCard.tsx +34 -0
- package/packages/react-css/src/components/AdapterConfigForm.tsx +63 -0
- package/packages/react-css/src/components/AdapterList.tsx +40 -0
- package/packages/react-css/src/components/AdapterStatusBadge.tsx +21 -0
- package/packages/react-css/src/components/index.ts +4 -0
- package/packages/react-css/src/hooks/index.ts +75 -0
- package/packages/react-css/src/index.tsx +25 -0
- package/packages/react-css/src/pages/AdapterDetailPage.tsx +133 -0
- package/packages/react-css/src/pages/AdaptersPage.tsx +111 -0
- package/packages/react-css/src/pages/index.ts +2 -0
- package/packages/react-css/src/provider/AdapterProvider.tsx +115 -0
- package/packages/react-css/src/provider/index.ts +2 -0
- package/packages/react-css/src/styles.css +494 -0
- package/packages/react-css/tsconfig.json +19 -0
- package/packages/react-css/tsup.config.ts +2 -0
- package/packages/shared/README.md +1 -0
- package/packages/shared/package.json +39 -0
- package/packages/shared/src/__tests__/adapters.test.ts +545 -0
- package/packages/shared/src/admin/index.ts +2 -0
- package/packages/shared/src/admin/interface.ts +34 -0
- package/packages/shared/src/admin/localStorage.ts +109 -0
- package/packages/shared/src/ai/anthropic.ts +123 -0
- package/packages/shared/src/ai/cloudflare-gateway.ts +130 -0
- package/packages/shared/src/ai/gemini.ts +181 -0
- package/packages/shared/src/ai/index.ts +14 -0
- package/packages/shared/src/ai/interface.ts +11 -0
- package/packages/shared/src/ai/localStorage.ts +78 -0
- package/packages/shared/src/ai/ollama.ts +143 -0
- package/packages/shared/src/ai/openai.ts +120 -0
- package/packages/shared/src/ai/vercel-ai.ts +101 -0
- package/packages/shared/src/auth/better-auth.ts +118 -0
- package/packages/shared/src/auth/clerk.ts +151 -0
- package/packages/shared/src/auth/convex-auth.ts +125 -0
- package/packages/shared/src/auth/index.ts +10 -0
- package/packages/shared/src/auth/interface.ts +17 -0
- package/packages/shared/src/auth/localStorage.ts +125 -0
- package/packages/shared/src/auth/supabase-auth.ts +136 -0
- package/packages/shared/src/config.ts +57 -0
- package/packages/shared/src/constants.ts +122 -0
- package/packages/shared/src/db/convex.ts +146 -0
- package/packages/shared/src/db/index.ts +10 -0
- package/packages/shared/src/db/interface.ts +13 -0
- package/packages/shared/src/db/localStorage.ts +91 -0
- package/packages/shared/src/db/mongodb.ts +125 -0
- package/packages/shared/src/db/neon.ts +171 -0
- package/packages/shared/src/db/supabase.ts +158 -0
- package/packages/shared/src/index.ts +117 -0
- package/packages/shared/src/payments/index.ts +4 -0
- package/packages/shared/src/payments/interface.ts +11 -0
- package/packages/shared/src/payments/localStorage.ts +81 -0
- package/packages/shared/src/payments/stripe.ts +177 -0
- package/packages/shared/src/storage/convex.ts +113 -0
- package/packages/shared/src/storage/index.ts +14 -0
- package/packages/shared/src/storage/interface.ts +11 -0
- package/packages/shared/src/storage/localStorage.ts +95 -0
- package/packages/shared/src/storage/minio.ts +47 -0
- package/packages/shared/src/storage/r2.ts +123 -0
- package/packages/shared/src/storage/s3.ts +128 -0
- package/packages/shared/src/storage/supabase-storage.ts +116 -0
- package/packages/shared/src/storage/uploadthing.ts +126 -0
- package/packages/shared/src/styles/adapters.css +494 -0
- package/packages/shared/src/tier-gate.ts +119 -0
- package/packages/shared/src/types.ts +162 -0
- package/packages/shared/tsconfig.json +18 -0
- package/packages/shared/tsup.config.ts +9 -0
- package/packages/shared/vitest.config.ts +14 -0
- package/packages/solidjs/README.md +1 -0
- package/packages/solidjs/package.json +44 -0
- package/packages/solidjs/src/components/AdapterCard.tsx +24 -0
- package/packages/solidjs/src/components/AdapterConfigForm.tsx +54 -0
- package/packages/solidjs/src/components/AdapterList.tsx +28 -0
- package/packages/solidjs/src/components/AdapterStatusBadge.tsx +20 -0
- package/packages/solidjs/src/components/index.ts +4 -0
- package/packages/solidjs/src/index.tsx +17 -0
- package/packages/solidjs/src/pages/AdapterDetailPage.tsx +38 -0
- package/packages/solidjs/src/pages/AdaptersPage.tsx +39 -0
- package/packages/solidjs/src/pages/index.ts +2 -0
- package/packages/solidjs/src/primitives/index.ts +78 -0
- package/packages/solidjs/src/provider/AdapterProvider.tsx +62 -0
- package/packages/solidjs/src/provider/index.ts +2 -0
- package/packages/solidjs/tsconfig.json +20 -0
- package/packages/solidjs/tsup.config.ts +10 -0
- package/packages/solidjs-css/README.md +1 -0
- package/packages/solidjs-css/package.json +43 -0
- package/packages/solidjs-css/src/adapters.css +1576 -0
- package/packages/solidjs-css/src/components/AdapterCard.tsx +43 -0
- package/packages/solidjs-css/src/components/AdapterConfigForm.tsx +119 -0
- package/packages/solidjs-css/src/components/AdapterList.tsx +68 -0
- package/packages/solidjs-css/src/components/AdapterStatusBadge.tsx +24 -0
- package/packages/solidjs-css/src/components/index.ts +8 -0
- package/packages/solidjs-css/src/index.tsx +30 -0
- package/packages/solidjs-css/src/pages/AdapterDetailPage.tsx +107 -0
- package/packages/solidjs-css/src/pages/AdaptersPage.tsx +94 -0
- package/packages/solidjs-css/src/pages/index.ts +4 -0
- package/packages/solidjs-css/src/primitives/index.ts +1 -0
- package/packages/solidjs-css/src/provider/AdapterProvider.tsx +61 -0
- package/packages/solidjs-css/src/provider/index.ts +2 -0
- package/packages/solidjs-css/tsconfig.json +20 -0
- package/packages/solidjs-css/tsup.config.ts +2 -0
- package/pnpm-workspace.yaml +2 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @geenius/adapters — localStorage Payments implementation (MVP tier mock)
|
|
2
|
+
|
|
3
|
+
import type { Plan, Subscription, CheckoutParams, CheckoutResult } from '../types'
|
|
4
|
+
import type { PaymentsAdapter } from './interface'
|
|
5
|
+
|
|
6
|
+
const SUBS_KEY = 'geenius_subscriptions'
|
|
7
|
+
const PLANS_KEY = 'geenius_plans'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PLANS: Plan[] = [
|
|
10
|
+
{ id: 'free', name: 'Free', price: 0, currency: 'USD', interval: 'month', features: ['Basic features', '1 project', 'Community support'] },
|
|
11
|
+
{ id: 'pro', name: 'Pro', price: 29, currency: 'USD', interval: 'month', features: ['All Free features', 'Unlimited projects', 'Priority support', 'AI features'] },
|
|
12
|
+
{ id: 'team', name: 'Team', price: 79, currency: 'USD', interval: 'month', features: ['All Pro features', 'Team management', 'Admin dashboard', 'Custom integrations'] },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
function getSubs(): Subscription[] {
|
|
16
|
+
try { return JSON.parse(localStorage.getItem(SUBS_KEY) || '[]') } catch { return [] }
|
|
17
|
+
}
|
|
18
|
+
function saveSubs(subs: Subscription[]) { localStorage.setItem(SUBS_KEY, JSON.stringify(subs)) }
|
|
19
|
+
|
|
20
|
+
export function createLocalStoragePaymentsAdapter(): PaymentsAdapter {
|
|
21
|
+
// Seed default plans
|
|
22
|
+
if (!localStorage.getItem(PLANS_KEY)) {
|
|
23
|
+
localStorage.setItem(PLANS_KEY, JSON.stringify(DEFAULT_PLANS))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
async createCheckout(params: CheckoutParams) {
|
|
28
|
+
// Mock: instantly create a subscription
|
|
29
|
+
const subs = getSubs()
|
|
30
|
+
const sub: Subscription = {
|
|
31
|
+
id: crypto.randomUUID(),
|
|
32
|
+
userId: params.userId,
|
|
33
|
+
planId: params.planId,
|
|
34
|
+
status: 'active',
|
|
35
|
+
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
36
|
+
cancelAtPeriodEnd: false,
|
|
37
|
+
}
|
|
38
|
+
subs.push(sub)
|
|
39
|
+
saveSubs(subs)
|
|
40
|
+
return { url: params.successUrl || '/', sessionId: sub.id } as CheckoutResult
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async getSubscription(userId) {
|
|
44
|
+
const sub = getSubs().find(s => s.userId === userId && s.status === 'active')
|
|
45
|
+
return sub ?? null
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async cancelSubscription(subscriptionId) {
|
|
49
|
+
const subs = getSubs()
|
|
50
|
+
const idx = subs.findIndex(s => s.id === subscriptionId)
|
|
51
|
+
if (idx !== -1) {
|
|
52
|
+
subs[idx].cancelAtPeriodEnd = true
|
|
53
|
+
subs[idx].status = 'cancelled'
|
|
54
|
+
saveSubs(subs)
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async getPlans() {
|
|
59
|
+
try { return JSON.parse(localStorage.getItem(PLANS_KEY) || '[]') } catch { return DEFAULT_PLANS }
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async isFeatureEnabled(userId, feature) {
|
|
63
|
+
const sub = await this.getSubscription(userId)
|
|
64
|
+
if (!sub) return false
|
|
65
|
+
const plans: Plan[] = await this.getPlans()
|
|
66
|
+
const plan = plans.find(p => p.id === sub.planId)
|
|
67
|
+
return plan?.features.includes(feature) ?? false
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// No-op adapter for Pronto tier (payments completely disabled)
|
|
73
|
+
export function createNoopPaymentsAdapter(): PaymentsAdapter {
|
|
74
|
+
return {
|
|
75
|
+
async createCheckout() { throw new Error('Payments not available in Pronto tier. Upgrade to Lancio.') },
|
|
76
|
+
async getSubscription() { return null },
|
|
77
|
+
async cancelSubscription() {},
|
|
78
|
+
async getPlans() { return [] },
|
|
79
|
+
async isFeatureEnabled() { return false },
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// @geenius/adapters — Stripe payments implementation (MVP/Paid tier)
|
|
2
|
+
// Wraps the Stripe Node.js SDK to conform to PaymentsAdapter interface.
|
|
3
|
+
// Requires: stripe
|
|
4
|
+
|
|
5
|
+
import type { Plan, Subscription, CheckoutParams, CheckoutResult } from '../types'
|
|
6
|
+
import type { PaymentsAdapter } from './interface'
|
|
7
|
+
|
|
8
|
+
interface StripeClient {
|
|
9
|
+
checkout: {
|
|
10
|
+
sessions: {
|
|
11
|
+
create: (params: any) => Promise<{ id: string; url: string | null }>
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
subscriptions: {
|
|
15
|
+
retrieve: (id: string) => Promise<{
|
|
16
|
+
id: string
|
|
17
|
+
customer: string
|
|
18
|
+
status: string
|
|
19
|
+
current_period_end: number
|
|
20
|
+
cancel_at_period_end: boolean
|
|
21
|
+
items: { data: Array<{ price: { id: string; product: string } }> }
|
|
22
|
+
}>
|
|
23
|
+
cancel: (id: string) => Promise<any>
|
|
24
|
+
}
|
|
25
|
+
prices: {
|
|
26
|
+
list: (params?: any) => Promise<{
|
|
27
|
+
data: Array<{
|
|
28
|
+
id: string
|
|
29
|
+
unit_amount: number | null
|
|
30
|
+
currency: string
|
|
31
|
+
recurring: { interval: string } | null
|
|
32
|
+
product: string | { id: string; name: string; metadata?: Record<string, string> }
|
|
33
|
+
}>
|
|
34
|
+
}>
|
|
35
|
+
}
|
|
36
|
+
products: {
|
|
37
|
+
retrieve: (id: string) => Promise<{
|
|
38
|
+
id: string
|
|
39
|
+
name: string
|
|
40
|
+
metadata?: Record<string, string>
|
|
41
|
+
}>
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StripePaymentsAdapterOptions {
|
|
46
|
+
/** Pre-configured Stripe client instance */
|
|
47
|
+
client: StripeClient
|
|
48
|
+
/** Map of plan IDs to Stripe price IDs */
|
|
49
|
+
priceMappings?: Record<string, string>
|
|
50
|
+
/** Default success URL for checkout */
|
|
51
|
+
successUrl?: string
|
|
52
|
+
/** Default cancel URL for checkout */
|
|
53
|
+
cancelUrl?: string
|
|
54
|
+
/**
|
|
55
|
+
* Lookup a Stripe subscription ID by userId.
|
|
56
|
+
* The adapter cannot look this up directly — your app layer must map
|
|
57
|
+
* userId → Stripe subscriptionId (typically stored in your DB).
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* subscriptionLookup: async (userId) => {
|
|
61
|
+
* const user = await db.users.findById(userId)
|
|
62
|
+
* return user?.stripeSubscriptionId ?? null
|
|
63
|
+
* }
|
|
64
|
+
*/
|
|
65
|
+
subscriptionLookup?: (userId: string) => Promise<string | null>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createStripePaymentsAdapter(options: StripePaymentsAdapterOptions): PaymentsAdapter {
|
|
69
|
+
const {
|
|
70
|
+
client,
|
|
71
|
+
priceMappings = {},
|
|
72
|
+
successUrl = '/billing/success',
|
|
73
|
+
cancelUrl = '/billing/cancel',
|
|
74
|
+
subscriptionLookup,
|
|
75
|
+
} = options
|
|
76
|
+
|
|
77
|
+
// Cache plans to avoid repeated API calls
|
|
78
|
+
let cachedPlans: Plan[] | null = null
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
async createCheckout(params: CheckoutParams): Promise<CheckoutResult> {
|
|
82
|
+
const priceId = priceMappings[params.planId] || params.planId
|
|
83
|
+
|
|
84
|
+
const session = await client.checkout.sessions.create({
|
|
85
|
+
mode: 'subscription',
|
|
86
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
87
|
+
success_url: params.successUrl || successUrl,
|
|
88
|
+
cancel_url: params.cancelUrl || cancelUrl,
|
|
89
|
+
client_reference_id: params.userId,
|
|
90
|
+
metadata: { userId: params.userId, planId: params.planId },
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
url: session.url || '',
|
|
95
|
+
sessionId: session.id,
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async getSubscription(userId: string): Promise<Subscription | null> {
|
|
100
|
+
if (!subscriptionLookup) return null
|
|
101
|
+
try {
|
|
102
|
+
const subscriptionId = await subscriptionLookup(userId)
|
|
103
|
+
if (!subscriptionId) return null
|
|
104
|
+
const raw = await client.subscriptions.retrieve(subscriptionId)
|
|
105
|
+
return {
|
|
106
|
+
id: raw.id,
|
|
107
|
+
userId,
|
|
108
|
+
planId: raw.items.data[0]?.price.id ?? '',
|
|
109
|
+
status: (['active', 'cancelled', 'past_due', 'trialing'] as const).includes(raw.status as any)
|
|
110
|
+
? (raw.status as Subscription['status'])
|
|
111
|
+
: 'past_due',
|
|
112
|
+
currentPeriodEnd: new Date(raw.current_period_end * 1000).toISOString(),
|
|
113
|
+
cancelAtPeriodEnd: raw.cancel_at_period_end,
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async cancelSubscription(subscriptionId: string): Promise<void> {
|
|
121
|
+
await client.subscriptions.cancel(subscriptionId)
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async getPlans(): Promise<Plan[]> {
|
|
125
|
+
if (cachedPlans) return cachedPlans
|
|
126
|
+
|
|
127
|
+
const { data: prices } = await client.prices.list({
|
|
128
|
+
active: true,
|
|
129
|
+
expand: ['data.product'],
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
cachedPlans = await Promise.all(
|
|
133
|
+
prices
|
|
134
|
+
.filter((p) => p.recurring)
|
|
135
|
+
.map(async (price) => {
|
|
136
|
+
let productName = 'Unknown'
|
|
137
|
+
let features: string[] = []
|
|
138
|
+
|
|
139
|
+
if (typeof price.product === 'object' && price.product.name) {
|
|
140
|
+
productName = price.product.name
|
|
141
|
+
features = price.product.metadata?.features
|
|
142
|
+
? price.product.metadata.features.split(',').map((f) => f.trim())
|
|
143
|
+
: []
|
|
144
|
+
} else if (typeof price.product === 'string') {
|
|
145
|
+
const product = await client.products.retrieve(price.product)
|
|
146
|
+
productName = product.name
|
|
147
|
+
features = product.metadata?.features
|
|
148
|
+
? product.metadata.features.split(',').map((f) => f.trim())
|
|
149
|
+
: []
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
id: price.id,
|
|
154
|
+
name: productName,
|
|
155
|
+
price: (price.unit_amount || 0) / 100,
|
|
156
|
+
currency: price.currency,
|
|
157
|
+
interval: (price.recurring?.interval === 'year' ? 'year' : 'month') as Plan['interval'],
|
|
158
|
+
features,
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return cachedPlans
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async isFeatureEnabled(userId: string, feature: string): Promise<boolean> {
|
|
167
|
+
// In a real implementation, look up the user's subscription and check
|
|
168
|
+
// if their plan includes the feature. Simplified: always return true.
|
|
169
|
+
const subscription = await this.getSubscription(userId)
|
|
170
|
+
if (!subscription || subscription.status !== 'active') return false
|
|
171
|
+
|
|
172
|
+
const plans = await this.getPlans()
|
|
173
|
+
const plan = plans.find((p) => p.id === subscription.planId)
|
|
174
|
+
return plan?.features.includes(feature) ?? false
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @geenius/adapters — Convex file storage adapter
|
|
2
|
+
// Wraps Convex's built-in storage API (generateUploadUrl, storage.getUrl, etc.)
|
|
3
|
+
// Requires a Convex client instance passed by the consuming app.
|
|
4
|
+
|
|
5
|
+
import type { StoredFile } from '../types'
|
|
6
|
+
import type { FileStorageAdapter } from './interface'
|
|
7
|
+
|
|
8
|
+
export interface ConvexFileAdapterOptions {
|
|
9
|
+
/** Convex client instance (e.g. from useConvex() or ConvexHttpClient) */
|
|
10
|
+
client: any
|
|
11
|
+
/** Convex API reference for file storage mutations/queries */
|
|
12
|
+
api: {
|
|
13
|
+
generateUploadUrl?: any
|
|
14
|
+
saveFile?: any
|
|
15
|
+
deleteFile?: any
|
|
16
|
+
listFiles?: any
|
|
17
|
+
getFile?: any
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createConvexFileAdapter(options: ConvexFileAdapterOptions): FileStorageAdapter {
|
|
22
|
+
const { client, api } = options
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
async upload(file, path, name?) {
|
|
26
|
+
const id = crypto.randomUUID()
|
|
27
|
+
const fileName = name || (file instanceof File ? file.name : `file-${id.slice(0, 8)}`)
|
|
28
|
+
|
|
29
|
+
// Step 1: Get a presigned upload URL from Convex
|
|
30
|
+
const uploadUrl = await client.mutation(api.generateUploadUrl)
|
|
31
|
+
|
|
32
|
+
// Step 2: Upload the file to Convex storage
|
|
33
|
+
const response = await fetch(uploadUrl, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
36
|
+
body: file,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new Error(`Convex upload failed: ${response.statusText}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { storageId } = await response.json()
|
|
44
|
+
|
|
45
|
+
// Step 3: Save file metadata in Convex DB
|
|
46
|
+
if (api.saveFile) {
|
|
47
|
+
await client.mutation(api.saveFile, {
|
|
48
|
+
storageId,
|
|
49
|
+
name: fileName,
|
|
50
|
+
originalName: fileName,
|
|
51
|
+
mimeType: file.type || 'application/octet-stream',
|
|
52
|
+
size: file.size,
|
|
53
|
+
folder: path || undefined,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Step 4: Get the public URL
|
|
58
|
+
const url = await client.query(api.getFile, { storageId }) ?? `convex://${storageId}`
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: storageId,
|
|
62
|
+
name: fileName,
|
|
63
|
+
path: path ? `${path}/${fileName}` : fileName,
|
|
64
|
+
size: file.size,
|
|
65
|
+
mimeType: file.type || 'application/octet-stream',
|
|
66
|
+
url,
|
|
67
|
+
createdAt: new Date().toISOString(),
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async download(fileId) {
|
|
72
|
+
// Convex storage URLs are directly fetchable
|
|
73
|
+
const url = await client.query(api.getFile, { storageId: fileId })
|
|
74
|
+
if (!url) throw new Error(`File not found: ${fileId}`)
|
|
75
|
+
const response = await fetch(url)
|
|
76
|
+
if (!response.ok) throw new Error(`Failed to download file: ${fileId}`)
|
|
77
|
+
return response.blob()
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async delete(fileId) {
|
|
81
|
+
try {
|
|
82
|
+
if (api.deleteFile) {
|
|
83
|
+
await client.mutation(api.deleteFile, { id: fileId })
|
|
84
|
+
}
|
|
85
|
+
return true
|
|
86
|
+
} catch {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async list(prefix?) {
|
|
92
|
+
if (!api.listFiles) return []
|
|
93
|
+
const files = await client.query(api.listFiles, { folder: prefix })
|
|
94
|
+
return (files || []).map((f: any): StoredFile => ({
|
|
95
|
+
id: f._id || f.storageId,
|
|
96
|
+
name: f.name || f.originalName,
|
|
97
|
+
path: f.folder ? `${f.folder}/${f.name}` : f.name,
|
|
98
|
+
size: f.size || 0,
|
|
99
|
+
mimeType: f.mimeType || 'application/octet-stream',
|
|
100
|
+
url: f.url || '',
|
|
101
|
+
createdAt: f.createdAt ? new Date(f.createdAt).toISOString() : new Date().toISOString(),
|
|
102
|
+
}))
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async getUrl(fileId) {
|
|
106
|
+
if (api.getFile) {
|
|
107
|
+
const url = await client.query(api.getFile, { storageId: fileId })
|
|
108
|
+
if (url) return url
|
|
109
|
+
}
|
|
110
|
+
return `convex://${fileId}`
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type { FileStorageAdapter } from './interface'
|
|
2
|
+
export { createLocalStorageFileAdapter } from './localStorage'
|
|
3
|
+
export { createR2FileAdapter } from './r2'
|
|
4
|
+
export type { R2FileAdapterOptions } from './r2'
|
|
5
|
+
export { createS3FileAdapter } from './s3'
|
|
6
|
+
export type { S3FileAdapterOptions } from './s3'
|
|
7
|
+
export { createUploadthingFileAdapter } from './uploadthing'
|
|
8
|
+
export type { UploadthingFileAdapterOptions } from './uploadthing'
|
|
9
|
+
export { createSupabaseFileAdapter } from './supabase-storage'
|
|
10
|
+
export type { SupabaseFileAdapterOptions } from './supabase-storage'
|
|
11
|
+
export { createConvexFileAdapter } from './convex'
|
|
12
|
+
export type { ConvexFileAdapterOptions } from './convex'
|
|
13
|
+
export { createMinioFileAdapter } from './minio'
|
|
14
|
+
export type { MinioFileAdapterOptions } from './minio'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @geenius/adapters — File Storage adapter interface
|
|
2
|
+
|
|
3
|
+
import type { StoredFile } from '../types'
|
|
4
|
+
|
|
5
|
+
export interface FileStorageAdapter {
|
|
6
|
+
upload(file: File | Blob, path: string, name?: string): Promise<StoredFile>
|
|
7
|
+
download(fileId: string): Promise<Blob>
|
|
8
|
+
delete(fileId: string): Promise<boolean>
|
|
9
|
+
list(prefix?: string): Promise<StoredFile[]>
|
|
10
|
+
getUrl(fileId: string): Promise<string>
|
|
11
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @geenius/adapters — localStorage File Storage implementation (base64 blobs)
|
|
2
|
+
|
|
3
|
+
import type { StoredFile } from '../types'
|
|
4
|
+
import type { FileStorageAdapter } from './interface'
|
|
5
|
+
|
|
6
|
+
const FILES_KEY = 'geenius_files'
|
|
7
|
+
const BLOBS_PREFIX = 'geenius_blob_'
|
|
8
|
+
|
|
9
|
+
interface StoredFileEntry extends StoredFile {
|
|
10
|
+
blobKey: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getFiles(): StoredFileEntry[] {
|
|
14
|
+
try { return JSON.parse(localStorage.getItem(FILES_KEY) || '[]') } catch { return [] }
|
|
15
|
+
}
|
|
16
|
+
function saveFiles(files: StoredFileEntry[]) { localStorage.setItem(FILES_KEY, JSON.stringify(files)) }
|
|
17
|
+
|
|
18
|
+
function blobToBase64(blob: Blob): Promise<string> {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const reader = new FileReader()
|
|
21
|
+
reader.onload = () => resolve(reader.result as string)
|
|
22
|
+
reader.onerror = reject
|
|
23
|
+
reader.readAsDataURL(blob)
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function base64ToBlob(dataUrl: string): Blob {
|
|
28
|
+
const [meta, data] = dataUrl.split(',')
|
|
29
|
+
const mime = meta.match(/:(.*?);/)?.[1] || 'application/octet-stream'
|
|
30
|
+
const bytes = atob(data)
|
|
31
|
+
const buffer = new Uint8Array(bytes.length)
|
|
32
|
+
for (let i = 0; i < bytes.length; i++) buffer[i] = bytes.charCodeAt(i)
|
|
33
|
+
return new Blob([buffer], { type: mime })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createLocalStorageFileAdapter(): FileStorageAdapter {
|
|
37
|
+
return {
|
|
38
|
+
async upload(file, path, name?) {
|
|
39
|
+
const base64 = await blobToBase64(file)
|
|
40
|
+
const id = crypto.randomUUID()
|
|
41
|
+
const blobKey = BLOBS_PREFIX + id
|
|
42
|
+
|
|
43
|
+
// Store the blob data separately (can be large)
|
|
44
|
+
localStorage.setItem(blobKey, base64)
|
|
45
|
+
|
|
46
|
+
const entry: StoredFileEntry = {
|
|
47
|
+
id,
|
|
48
|
+
name: name || (file instanceof File ? file.name : `file-${id.slice(0, 8)}`),
|
|
49
|
+
path,
|
|
50
|
+
size: file.size,
|
|
51
|
+
mimeType: file.type || 'application/octet-stream',
|
|
52
|
+
url: `local://${id}`,
|
|
53
|
+
createdAt: new Date().toISOString(),
|
|
54
|
+
blobKey,
|
|
55
|
+
}
|
|
56
|
+
const files = getFiles()
|
|
57
|
+
files.push(entry)
|
|
58
|
+
saveFiles(files)
|
|
59
|
+
|
|
60
|
+
const { blobKey: _, ...storedFile } = entry
|
|
61
|
+
return storedFile
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async download(fileId) {
|
|
65
|
+
const entry = getFiles().find(f => f.id === fileId)
|
|
66
|
+
if (!entry) throw new Error(`File not found: ${fileId}`)
|
|
67
|
+
const base64 = localStorage.getItem(entry.blobKey)
|
|
68
|
+
if (!base64) throw new Error(`Blob data missing for file: ${fileId}`)
|
|
69
|
+
return base64ToBlob(base64)
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async delete(fileId) {
|
|
73
|
+
const files = getFiles()
|
|
74
|
+
const idx = files.findIndex(f => f.id === fileId)
|
|
75
|
+
if (idx === -1) return false
|
|
76
|
+
localStorage.removeItem(files[idx].blobKey)
|
|
77
|
+
files.splice(idx, 1)
|
|
78
|
+
saveFiles(files)
|
|
79
|
+
return true
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async list(prefix?) {
|
|
83
|
+
let files = getFiles()
|
|
84
|
+
if (prefix) files = files.filter(f => f.path.startsWith(prefix))
|
|
85
|
+
return files.map(({ blobKey: _, ...f }) => f)
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async getUrl(fileId) {
|
|
89
|
+
const entry = getFiles().find(f => f.id === fileId)
|
|
90
|
+
if (!entry) throw new Error(`File not found: ${fileId}`)
|
|
91
|
+
const base64 = localStorage.getItem(entry.blobKey)
|
|
92
|
+
return base64 || entry.url
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @geenius/adapters — Minio file storage adapter (S3-compatible, self-hosted)
|
|
2
|
+
// Requires: @aws-sdk/client-s3, @aws-sdk/s3-request-presigner
|
|
3
|
+
// Minio is 100% S3-compatible — this is a convenience wrapper around the S3 adapter
|
|
4
|
+
// with Minio-specific defaults.
|
|
5
|
+
|
|
6
|
+
import type { FileStorageAdapter } from './interface'
|
|
7
|
+
import { createS3FileAdapter } from './s3'
|
|
8
|
+
import type { S3FileAdapterOptions } from './s3'
|
|
9
|
+
|
|
10
|
+
export interface MinioFileAdapterOptions {
|
|
11
|
+
/** Minio endpoint URL (e.g. https://minio.example.com or http://localhost:9000) */
|
|
12
|
+
endpoint: string
|
|
13
|
+
/** Minio access key */
|
|
14
|
+
accessKeyId: string
|
|
15
|
+
/** Minio secret key */
|
|
16
|
+
secretAccessKey: string
|
|
17
|
+
/** Bucket name */
|
|
18
|
+
bucket: string
|
|
19
|
+
/** Use path-style URLs (default: true — required for Minio) */
|
|
20
|
+
forcePathStyle?: boolean
|
|
21
|
+
/** Custom public URL prefix */
|
|
22
|
+
publicUrl?: string
|
|
23
|
+
/** Presigned URL expiry in seconds (default: 3600) */
|
|
24
|
+
presignedExpiry?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Minio file storage adapter — self-hosted S3-compatible object storage.
|
|
29
|
+
* Wraps the S3 adapter with Minio-specific defaults.
|
|
30
|
+
* https://min.io
|
|
31
|
+
*/
|
|
32
|
+
export function createMinioFileAdapter(options: MinioFileAdapterOptions): FileStorageAdapter {
|
|
33
|
+
const { endpoint, accessKeyId, secretAccessKey, bucket, publicUrl, presignedExpiry } = options
|
|
34
|
+
|
|
35
|
+
// Minio uses the same S3 API — delegate to S3 adapter with custom endpoint
|
|
36
|
+
const s3Options: S3FileAdapterOptions = {
|
|
37
|
+
region: 'us-east-1', // Minio ignores region but S3 client requires it
|
|
38
|
+
bucket,
|
|
39
|
+
accessKeyId,
|
|
40
|
+
secretAccessKey,
|
|
41
|
+
endpoint,
|
|
42
|
+
publicUrl: publicUrl || `${endpoint}/${bucket}`,
|
|
43
|
+
presignedExpiry,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return createS3FileAdapter(s3Options)
|
|
47
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @geenius/adapters — Cloudflare R2 file storage adapter
|
|
2
|
+
// Uses S3-compatible API via @aws-sdk/client-s3
|
|
3
|
+
// Requires: @aws-sdk/client-s3, @aws-sdk/s3-request-presigner
|
|
4
|
+
|
|
5
|
+
import type { StoredFile } from '../types'
|
|
6
|
+
import type { FileStorageAdapter } from './interface'
|
|
7
|
+
|
|
8
|
+
export interface R2FileAdapterOptions {
|
|
9
|
+
/** R2 account ID */
|
|
10
|
+
accountId: string
|
|
11
|
+
/** R2 access key ID */
|
|
12
|
+
accessKeyId: string
|
|
13
|
+
/** R2 secret access key */
|
|
14
|
+
secretAccessKey: string
|
|
15
|
+
/** R2 bucket name */
|
|
16
|
+
bucket: string
|
|
17
|
+
/** Custom public URL prefix (e.g. https://files.example.com) */
|
|
18
|
+
publicUrl?: string
|
|
19
|
+
/** Presigned URL expiry in seconds (default: 3600) */
|
|
20
|
+
presignedExpiry?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createR2FileAdapter(options: R2FileAdapterOptions): FileStorageAdapter {
|
|
24
|
+
const { bucket, accountId, accessKeyId, secretAccessKey, publicUrl, presignedExpiry = 3600 } = options
|
|
25
|
+
|
|
26
|
+
let s3Client: any = null
|
|
27
|
+
let getSignedUrl: any = null
|
|
28
|
+
let commands: any = null
|
|
29
|
+
|
|
30
|
+
async function getClient() {
|
|
31
|
+
if (!s3Client) {
|
|
32
|
+
const { S3Client } = await import('@aws-sdk/client-s3')
|
|
33
|
+
const presigner = await import('@aws-sdk/s3-request-presigner')
|
|
34
|
+
const s3Commands = await import('@aws-sdk/client-s3')
|
|
35
|
+
|
|
36
|
+
s3Client = new S3Client({
|
|
37
|
+
region: 'auto',
|
|
38
|
+
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
|
39
|
+
credentials: { accessKeyId, secretAccessKey },
|
|
40
|
+
})
|
|
41
|
+
getSignedUrl = presigner.getSignedUrl
|
|
42
|
+
commands = s3Commands
|
|
43
|
+
}
|
|
44
|
+
return { client: s3Client, getSignedUrl, commands }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
async upload(file, path, name?) {
|
|
49
|
+
const { client, commands: cmd } = await getClient()
|
|
50
|
+
const id = crypto.randomUUID()
|
|
51
|
+
const fileName = name || (file instanceof File ? file.name : `file-${id.slice(0, 8)}`)
|
|
52
|
+
const key = path ? `${path}/${fileName}` : fileName
|
|
53
|
+
const buffer = new Uint8Array(await file.arrayBuffer())
|
|
54
|
+
|
|
55
|
+
await client.send(new cmd.PutObjectCommand({
|
|
56
|
+
Bucket: bucket,
|
|
57
|
+
Key: key,
|
|
58
|
+
Body: buffer,
|
|
59
|
+
ContentType: file.type || 'application/octet-stream',
|
|
60
|
+
Metadata: { 'original-name': fileName, 'file-id': id },
|
|
61
|
+
}))
|
|
62
|
+
|
|
63
|
+
const url = publicUrl ? `${publicUrl}/${key}` : `https://${accountId}.r2.cloudflarestorage.com/${bucket}/${key}`
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
id,
|
|
67
|
+
name: fileName,
|
|
68
|
+
path: key,
|
|
69
|
+
size: file.size,
|
|
70
|
+
mimeType: file.type || 'application/octet-stream',
|
|
71
|
+
url,
|
|
72
|
+
createdAt: new Date().toISOString(),
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async download(fileId) {
|
|
77
|
+
const { client, commands: cmd } = await getClient()
|
|
78
|
+
// fileId is used as the key/path
|
|
79
|
+
const response = await client.send(new cmd.GetObjectCommand({
|
|
80
|
+
Bucket: bucket,
|
|
81
|
+
Key: fileId,
|
|
82
|
+
}))
|
|
83
|
+
const bytes = await response.Body?.transformToByteArray()
|
|
84
|
+
if (!bytes) throw new Error(`Failed to download file: ${fileId}`)
|
|
85
|
+
return new Blob([bytes], { type: response.ContentType || 'application/octet-stream' })
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async delete(fileId) {
|
|
89
|
+
const { client, commands: cmd } = await getClient()
|
|
90
|
+
try {
|
|
91
|
+
await client.send(new cmd.DeleteObjectCommand({ Bucket: bucket, Key: fileId }))
|
|
92
|
+
return true
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async list(prefix?) {
|
|
99
|
+
const { client, commands: cmd } = await getClient()
|
|
100
|
+
const response = await client.send(new cmd.ListObjectsV2Command({
|
|
101
|
+
Bucket: bucket,
|
|
102
|
+
Prefix: prefix || undefined,
|
|
103
|
+
}))
|
|
104
|
+
|
|
105
|
+
return (response.Contents || []).map((obj: any): StoredFile => ({
|
|
106
|
+
id: obj.Key,
|
|
107
|
+
name: obj.Key.split('/').pop() || obj.Key,
|
|
108
|
+
path: obj.Key,
|
|
109
|
+
size: obj.Size || 0,
|
|
110
|
+
mimeType: 'application/octet-stream',
|
|
111
|
+
url: publicUrl ? `${publicUrl}/${obj.Key}` : `https://${accountId}.r2.cloudflarestorage.com/${bucket}/${obj.Key}`,
|
|
112
|
+
createdAt: obj.LastModified?.toISOString() || new Date().toISOString(),
|
|
113
|
+
}))
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async getUrl(fileId) {
|
|
117
|
+
if (publicUrl) return `${publicUrl}/${fileId}`
|
|
118
|
+
const { client, getSignedUrl: sign, commands: cmd } = await getClient()
|
|
119
|
+
const command = new cmd.GetObjectCommand({ Bucket: bucket, Key: fileId })
|
|
120
|
+
return sign(client, command, { expiresIn: presignedExpiry })
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|