ship-create 1.2.0 → 1.3.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/package.json +1 -1
- package/templates/starter-kit/.env.example +32 -0
- package/templates/starter-kit/README.md +26 -16
- package/templates/starter-kit/drizzle.config.ts +27 -0
- package/templates/starter-kit/lib/auth.ts +30 -0
- package/templates/starter-kit/lib/db/index.test.ts +53 -0
- package/templates/starter-kit/lib/db/index.ts +64 -0
- package/templates/starter-kit/lib/db/schema.pg.ts +58 -0
- package/templates/starter-kit/lib/db/schema.sqlite.ts +58 -0
- package/templates/starter-kit/lib/storage.test.ts +32 -0
- package/templates/starter-kit/lib/storage.ts +62 -0
- package/templates/starter-kit/package.json +11 -2
- package/templates/starter-kit/vitest.config.ts +7 -0
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Pick exactly one provider. This is a setup-time choice, not runtime-switchable.
|
|
2
|
+
DB_PROVIDER=supabase # supabase | neon | cloudflare-d1 | postgres
|
|
3
|
+
|
|
4
|
+
# --- supabase | postgres | neon (Postgres-family — all use DATABASE_URL) ---
|
|
5
|
+
# Supabase: Project Settings -> Database -> Connection string (use the "Transaction pooler" URI for serverless)
|
|
6
|
+
# Neon: Project Dashboard -> Connection Details -> select "Pooled connection"
|
|
7
|
+
# Plain Postgres: your host's connection string, e.g. postgres://user:pass@host:5432/dbname
|
|
8
|
+
DATABASE_URL=
|
|
9
|
+
|
|
10
|
+
# --- cloudflare-d1 only ---
|
|
11
|
+
# D1 has no connection string at runtime — the binding comes from wrangler.toml
|
|
12
|
+
# and the Cloudflare Workers runtime, not an env var. The vars below are only
|
|
13
|
+
# used by `drizzle-kit` migration commands run from your local machine.
|
|
14
|
+
CLOUDFLARE_ACCOUNT_ID=
|
|
15
|
+
CLOUDFLARE_D1_DATABASE_ID=
|
|
16
|
+
CLOUDFLARE_API_TOKEN=
|
|
17
|
+
|
|
18
|
+
# IMPORTANT: DB_PROVIDER=cloudflare-d1 requires deploying this app to
|
|
19
|
+
# Cloudflare Workers/Pages via @opennextjs/cloudflare. It will NOT work on
|
|
20
|
+
# Vercel. See 13-TECH-STACK/DB_PROVIDER_GUIDE.md before picking this option.
|
|
21
|
+
|
|
22
|
+
# --- Auth.js (required regardless of DB_PROVIDER) ---
|
|
23
|
+
AUTH_SECRET=
|
|
24
|
+
# Add at least one provider's keys here once chosen, e.g.:
|
|
25
|
+
# AUTH_GITHUB_ID=
|
|
26
|
+
# AUTH_GITHUB_SECRET=
|
|
27
|
+
|
|
28
|
+
# --- Storage (optional — only needed once you wire up file uploads) ---
|
|
29
|
+
STORAGE_PROVIDER=supabase # supabase | cloudflare-r2 | vercel-blob (only supabase is implemented)
|
|
30
|
+
SUPABASE_URL=
|
|
31
|
+
SUPABASE_SERVICE_ROLE_KEY=
|
|
32
|
+
SUPABASE_STORAGE_BUCKET=uploads
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# SHIP Starter Kit
|
|
2
2
|
|
|
3
|
-
This is the code foundation for **The SHIP Method OS** — a Next.js
|
|
3
|
+
This is the code foundation for **The SHIP Method OS** — a Next.js 16 (App
|
|
4
4
|
Router) + TypeScript + Tailwind CSS starter that gives every downstream agent
|
|
5
5
|
a consistent, working UI shell to build on top of.
|
|
6
6
|
|
|
7
|
-
It is currently **mock-data only**.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
button styles or color
|
|
7
|
+
It is currently **mock-data only**. Backend code (`lib/db/`, `lib/auth.ts`,
|
|
8
|
+
`lib/storage.ts`) is included but has no live database connection configured
|
|
9
|
+
by default. Every list, table, and metric you see is sourced from
|
|
10
|
+
`lib/mock-data.ts`. That's intentional: the goal of this layer is a correct,
|
|
11
|
+
consistent shared foundation (design tokens, primitives, layout) that other
|
|
12
|
+
agents can build real features on without re-deciding button styles or color
|
|
13
|
+
values per screen.
|
|
13
14
|
|
|
14
15
|
## Running it
|
|
15
16
|
|
|
@@ -51,14 +52,23 @@ shared primitives in `components/ui/` and the `cn()` helper in
|
|
|
51
52
|
|
|
52
53
|
## Next step: real data
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
`lib/
|
|
56
|
-
|
|
55
|
+
This kit ships with a real, pluggable backend in `lib/db/`, `lib/auth.ts`,
|
|
56
|
+
and `lib/storage.ts` — but it has no live database connection configured
|
|
57
|
+
yet. To turn it on:
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
1. Copy `.env.example` to `.env` and pick a `DB_PROVIDER`: `supabase`,
|
|
60
|
+
`neon`, `cloudflare-d1`, or `postgres`. Read
|
|
61
|
+
`../13-TECH-STACK/DB_PROVIDER_GUIDE.md` first if you're unsure which —
|
|
62
|
+
it covers free-tier limits and which providers can deploy where.
|
|
63
|
+
2. Fill in that provider's connection details in `.env` (see the comments
|
|
64
|
+
in `.env.example`).
|
|
65
|
+
3. Run `npx drizzle-kit generate` then apply the generated migration to
|
|
66
|
+
create the `user`/`account`/`session`/`verificationToken` tables.
|
|
67
|
+
4. Add at least one Auth.js provider (OAuth or credentials) to the empty
|
|
68
|
+
`providers: []` array in `lib/auth.ts` — auth won't work until you do.
|
|
69
|
+
5. Replace the contents of `lib/mock-data.ts` (or its call sites) with
|
|
70
|
+
real queries against `getDb()` from `lib/db`.
|
|
61
71
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
Before writing your own tables, also read
|
|
73
|
+
`../03-INSTRUCTION/DATABASE_SPEC.md` for the schema-design template this
|
|
74
|
+
app's data model should follow.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfig } from "drizzle-kit"
|
|
2
|
+
|
|
3
|
+
const provider = process.env.DB_PROVIDER
|
|
4
|
+
|
|
5
|
+
const config =
|
|
6
|
+
provider === "cloudflare-d1"
|
|
7
|
+
? defineConfig({
|
|
8
|
+
dialect: "sqlite",
|
|
9
|
+
schema: "./lib/db/schema.sqlite.ts",
|
|
10
|
+
out: "./drizzle/sqlite",
|
|
11
|
+
driver: "d1-http",
|
|
12
|
+
dbCredentials: {
|
|
13
|
+
accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "",
|
|
14
|
+
databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID ?? "",
|
|
15
|
+
token: process.env.CLOUDFLARE_API_TOKEN ?? "",
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
: defineConfig({
|
|
19
|
+
dialect: "postgresql",
|
|
20
|
+
schema: "./lib/db/schema.pg.ts",
|
|
21
|
+
out: "./drizzle/postgres",
|
|
22
|
+
dbCredentials: {
|
|
23
|
+
url: process.env.DATABASE_URL ?? "",
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export default config
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import NextAuth from "next-auth"
|
|
2
|
+
import { DrizzleAdapter } from "@auth/drizzle-adapter"
|
|
3
|
+
import { getDb } from "./db"
|
|
4
|
+
import * as pgSchema from "./db/schema.pg"
|
|
5
|
+
import * as sqliteSchema from "./db/schema.sqlite"
|
|
6
|
+
|
|
7
|
+
const provider = process.env.DB_PROVIDER
|
|
8
|
+
const schema = (provider === "cloudflare-d1" ? sqliteSchema : pgSchema) as any
|
|
9
|
+
|
|
10
|
+
// D1's binding only exists inside a Cloudflare request context, so the
|
|
11
|
+
// adapter is built per-request via NextAuth's config-function form instead
|
|
12
|
+
// of once at module scope (which is enough for every other provider).
|
|
13
|
+
export const { handlers, signIn, signOut, auth } = NextAuth(async () => {
|
|
14
|
+
if (provider === "cloudflare-d1") {
|
|
15
|
+
const { getCloudflareContext } = await import("@opennextjs/cloudflare")
|
|
16
|
+
const { env } = getCloudflareContext()
|
|
17
|
+
return {
|
|
18
|
+
adapter: DrizzleAdapter(getDb((env as { DB: unknown }).DB), schema),
|
|
19
|
+
// No providers configured yet — add at least one (OAuth or
|
|
20
|
+
// credentials) before this auth setup is functional.
|
|
21
|
+
providers: [],
|
|
22
|
+
session: { strategy: "database" },
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
adapter: DrizzleAdapter(getDb(), schema),
|
|
27
|
+
providers: [],
|
|
28
|
+
session: { strategy: "database" },
|
|
29
|
+
}
|
|
30
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
|
2
|
+
import { getDb } from "./index"
|
|
3
|
+
|
|
4
|
+
describe("getDb", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllEnvs()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("throws when DB_PROVIDER is missing", () => {
|
|
10
|
+
vi.stubEnv("DB_PROVIDER", "")
|
|
11
|
+
expect(() => getDb()).toThrow(/DB_PROVIDER/)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("throws when DB_PROVIDER is invalid", () => {
|
|
15
|
+
vi.stubEnv("DB_PROVIDER", "mongodb")
|
|
16
|
+
expect(() => getDb()).toThrow(/DB_PROVIDER/)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("throws when DB_PROVIDER=postgres and DATABASE_URL is missing", () => {
|
|
20
|
+
vi.stubEnv("DB_PROVIDER", "postgres")
|
|
21
|
+
vi.stubEnv("DATABASE_URL", "")
|
|
22
|
+
expect(() => getDb()).toThrow(/DATABASE_URL/)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("constructs a client for DB_PROVIDER=postgres given a connection string", () => {
|
|
26
|
+
vi.stubEnv("DB_PROVIDER", "postgres")
|
|
27
|
+
vi.stubEnv("DATABASE_URL", "postgres://user:pass@localhost:5432/db")
|
|
28
|
+
expect(() => getDb()).not.toThrow()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("constructs a client for DB_PROVIDER=supabase given a connection string", () => {
|
|
32
|
+
vi.stubEnv("DB_PROVIDER", "supabase")
|
|
33
|
+
vi.stubEnv("DATABASE_URL", "postgres://user:pass@localhost:5432/db")
|
|
34
|
+
expect(() => getDb()).not.toThrow()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("constructs a client for DB_PROVIDER=neon given a connection string", () => {
|
|
38
|
+
vi.stubEnv("DB_PROVIDER", "neon")
|
|
39
|
+
vi.stubEnv("DATABASE_URL", "postgres://user:pass@neon.example.com/db")
|
|
40
|
+
expect(() => getDb()).not.toThrow()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("throws when DB_PROVIDER=cloudflare-d1 and no binding is passed", () => {
|
|
44
|
+
vi.stubEnv("DB_PROVIDER", "cloudflare-d1")
|
|
45
|
+
expect(() => getDb()).toThrow(/d1Binding/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("constructs a client for DB_PROVIDER=cloudflare-d1 given a binding", () => {
|
|
49
|
+
vi.stubEnv("DB_PROVIDER", "cloudflare-d1")
|
|
50
|
+
const fakeBinding = {} as never
|
|
51
|
+
expect(() => getDb(fakeBinding)).not.toThrow()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { drizzle as drizzlePg } from "drizzle-orm/postgres-js"
|
|
2
|
+
import postgres from "postgres"
|
|
3
|
+
import { drizzle as drizzleNeon } from "drizzle-orm/neon-http"
|
|
4
|
+
import { neon } from "@neondatabase/serverless"
|
|
5
|
+
import { drizzle as drizzleD1 } from "drizzle-orm/d1"
|
|
6
|
+
import * as pgSchema from "./schema.pg"
|
|
7
|
+
import * as sqliteSchema from "./schema.sqlite"
|
|
8
|
+
|
|
9
|
+
export type DbProvider = "supabase" | "neon" | "cloudflare-d1" | "postgres"
|
|
10
|
+
|
|
11
|
+
function getProvider(): DbProvider {
|
|
12
|
+
const provider = process.env.DB_PROVIDER
|
|
13
|
+
if (
|
|
14
|
+
provider !== "supabase" &&
|
|
15
|
+
provider !== "neon" &&
|
|
16
|
+
provider !== "cloudflare-d1" &&
|
|
17
|
+
provider !== "postgres"
|
|
18
|
+
) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Invalid or missing DB_PROVIDER env var: "${provider}". Must be one of: supabase, neon, cloudflare-d1, postgres. See .env.example.`
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
return provider
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// D1's binding only exists inside a Cloudflare request context (there is no
|
|
27
|
+
// connection string for it), so callers on that path must pass it in.
|
|
28
|
+
export function getDb(d1Binding?: unknown) {
|
|
29
|
+
const provider = getProvider()
|
|
30
|
+
|
|
31
|
+
switch (provider) {
|
|
32
|
+
case "supabase":
|
|
33
|
+
case "postgres": {
|
|
34
|
+
const connectionString = process.env.DATABASE_URL
|
|
35
|
+
if (!connectionString) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`DATABASE_URL is required when DB_PROVIDER is "${provider}". See .env.example.`
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
const client = postgres(connectionString)
|
|
41
|
+
return drizzlePg(client, { schema: pgSchema })
|
|
42
|
+
}
|
|
43
|
+
case "neon": {
|
|
44
|
+
const connectionString = process.env.DATABASE_URL
|
|
45
|
+
if (!connectionString) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'DATABASE_URL is required when DB_PROVIDER is "neon". See .env.example.'
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
const client = neon(connectionString)
|
|
51
|
+
return drizzleNeon(client, { schema: pgSchema })
|
|
52
|
+
}
|
|
53
|
+
case "cloudflare-d1": {
|
|
54
|
+
if (!d1Binding) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'DB_PROVIDER is "cloudflare-d1" but no d1Binding was passed to getDb(). ' +
|
|
57
|
+
"D1's binding comes from the Cloudflare runtime context (e.g. getCloudflareContext().env.DB " +
|
|
58
|
+
"from @opennextjs/cloudflare) — it cannot be constructed from an env var alone."
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
return drizzleD1(d1Binding as never, { schema: sqliteSchema })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { integer, timestamp, pgTable, primaryKey, text } from "drizzle-orm/pg-core"
|
|
2
|
+
import type { AdapterAccountType } from "next-auth/adapters"
|
|
3
|
+
|
|
4
|
+
export const users = pgTable("user", {
|
|
5
|
+
id: text("id")
|
|
6
|
+
.primaryKey()
|
|
7
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
8
|
+
name: text("name"),
|
|
9
|
+
email: text("email").unique(),
|
|
10
|
+
emailVerified: timestamp("emailVerified", { mode: "date" }),
|
|
11
|
+
image: text("image"),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export const accounts = pgTable(
|
|
15
|
+
"account",
|
|
16
|
+
{
|
|
17
|
+
userId: text("userId")
|
|
18
|
+
.notNull()
|
|
19
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
20
|
+
type: text("type").$type<AdapterAccountType>().notNull(),
|
|
21
|
+
provider: text("provider").notNull(),
|
|
22
|
+
providerAccountId: text("providerAccountId").notNull(),
|
|
23
|
+
refresh_token: text("refresh_token"),
|
|
24
|
+
access_token: text("access_token"),
|
|
25
|
+
expires_at: integer("expires_at"),
|
|
26
|
+
token_type: text("token_type"),
|
|
27
|
+
scope: text("scope"),
|
|
28
|
+
id_token: text("id_token"),
|
|
29
|
+
session_state: text("session_state"),
|
|
30
|
+
},
|
|
31
|
+
(account) => ({
|
|
32
|
+
compoundKey: primaryKey({
|
|
33
|
+
columns: [account.provider, account.providerAccountId],
|
|
34
|
+
}),
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export const sessions = pgTable("session", {
|
|
39
|
+
sessionToken: text("sessionToken").primaryKey(),
|
|
40
|
+
userId: text("userId")
|
|
41
|
+
.notNull()
|
|
42
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
43
|
+
expires: timestamp("expires", { mode: "date" }).notNull(),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export const verificationTokens = pgTable(
|
|
47
|
+
"verificationToken",
|
|
48
|
+
{
|
|
49
|
+
identifier: text("identifier").notNull(),
|
|
50
|
+
token: text("token").notNull(),
|
|
51
|
+
expires: timestamp("expires", { mode: "date" }).notNull(),
|
|
52
|
+
},
|
|
53
|
+
(verificationToken) => ({
|
|
54
|
+
compositePk: primaryKey({
|
|
55
|
+
columns: [verificationToken.identifier, verificationToken.token],
|
|
56
|
+
}),
|
|
57
|
+
})
|
|
58
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
|
2
|
+
import type { AdapterAccountType } from "next-auth/adapters"
|
|
3
|
+
|
|
4
|
+
export const users = sqliteTable("user", {
|
|
5
|
+
id: text("id")
|
|
6
|
+
.primaryKey()
|
|
7
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
8
|
+
name: text("name"),
|
|
9
|
+
email: text("email").unique(),
|
|
10
|
+
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
|
|
11
|
+
image: text("image"),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export const accounts = sqliteTable(
|
|
15
|
+
"account",
|
|
16
|
+
{
|
|
17
|
+
userId: text("userId")
|
|
18
|
+
.notNull()
|
|
19
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
20
|
+
type: text("type").$type<AdapterAccountType>().notNull(),
|
|
21
|
+
provider: text("provider").notNull(),
|
|
22
|
+
providerAccountId: text("providerAccountId").notNull(),
|
|
23
|
+
refresh_token: text("refresh_token"),
|
|
24
|
+
access_token: text("access_token"),
|
|
25
|
+
expires_at: integer("expires_at"),
|
|
26
|
+
token_type: text("token_type"),
|
|
27
|
+
scope: text("scope"),
|
|
28
|
+
id_token: text("id_token"),
|
|
29
|
+
session_state: text("session_state"),
|
|
30
|
+
},
|
|
31
|
+
(account) => ({
|
|
32
|
+
compoundKey: primaryKey({
|
|
33
|
+
columns: [account.provider, account.providerAccountId],
|
|
34
|
+
}),
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export const sessions = sqliteTable("session", {
|
|
39
|
+
sessionToken: text("sessionToken").primaryKey(),
|
|
40
|
+
userId: text("userId")
|
|
41
|
+
.notNull()
|
|
42
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
43
|
+
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export const verificationTokens = sqliteTable(
|
|
47
|
+
"verificationToken",
|
|
48
|
+
{
|
|
49
|
+
identifier: text("identifier").notNull(),
|
|
50
|
+
token: text("token").notNull(),
|
|
51
|
+
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
|
|
52
|
+
},
|
|
53
|
+
(verificationToken) => ({
|
|
54
|
+
compositePk: primaryKey({
|
|
55
|
+
columns: [verificationToken.identifier, verificationToken.token],
|
|
56
|
+
}),
|
|
57
|
+
})
|
|
58
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest"
|
|
2
|
+
import { createStorageClient } from "./storage"
|
|
3
|
+
|
|
4
|
+
describe("createStorageClient", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllEnvs()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("throws when STORAGE_PROVIDER=supabase and SUPABASE_URL is missing", () => {
|
|
10
|
+
vi.stubEnv("STORAGE_PROVIDER", "supabase")
|
|
11
|
+
vi.stubEnv("SUPABASE_URL", "")
|
|
12
|
+
vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "")
|
|
13
|
+
expect(() => createStorageClient()).toThrow(/SUPABASE_URL/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("constructs a client when STORAGE_PROVIDER=supabase with credentials set", () => {
|
|
17
|
+
vi.stubEnv("STORAGE_PROVIDER", "supabase")
|
|
18
|
+
vi.stubEnv("SUPABASE_URL", "https://example.supabase.co")
|
|
19
|
+
vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "fake-key")
|
|
20
|
+
expect(() => createStorageClient()).not.toThrow()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("throws a clear not-implemented error for cloudflare-r2", () => {
|
|
24
|
+
vi.stubEnv("STORAGE_PROVIDER", "cloudflare-r2")
|
|
25
|
+
expect(() => createStorageClient()).toThrow(/not implemented/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("throws a clear not-implemented error for vercel-blob", () => {
|
|
29
|
+
vi.stubEnv("STORAGE_PROVIDER", "vercel-blob")
|
|
30
|
+
expect(() => createStorageClient()).toThrow(/not implemented/)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createClient } from "@supabase/supabase-js"
|
|
2
|
+
|
|
3
|
+
export interface StorageClient {
|
|
4
|
+
upload(
|
|
5
|
+
path: string,
|
|
6
|
+
file: Blob | Buffer,
|
|
7
|
+
contentType: string
|
|
8
|
+
): Promise<{ path: string; publicUrl: string }>
|
|
9
|
+
getPublicUrl(path: string): string
|
|
10
|
+
remove(path: string): Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class SupabaseStorageClient implements StorageClient {
|
|
14
|
+
private client: ReturnType<typeof createClient>
|
|
15
|
+
private bucket: string
|
|
16
|
+
|
|
17
|
+
constructor(supabaseUrl: string, supabaseKey: string, bucket: string) {
|
|
18
|
+
this.client = createClient(supabaseUrl, supabaseKey)
|
|
19
|
+
this.bucket = bucket
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async upload(path: string, file: Blob | Buffer, contentType: string) {
|
|
23
|
+
const { data, error } = await this.client.storage
|
|
24
|
+
.from(this.bucket)
|
|
25
|
+
.upload(path, file, { contentType, upsert: true })
|
|
26
|
+
if (error) throw error
|
|
27
|
+
return { path: data.path, publicUrl: this.getPublicUrl(data.path) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getPublicUrl(path: string): string {
|
|
31
|
+
const { data } = this.client.storage.from(this.bucket).getPublicUrl(path)
|
|
32
|
+
return data.publicUrl
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async remove(path: string): Promise<void> {
|
|
36
|
+
const { error } = await this.client.storage.from(this.bucket).remove([path])
|
|
37
|
+
if (error) throw error
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type StorageProvider = "supabase" | "cloudflare-r2" | "vercel-blob"
|
|
42
|
+
|
|
43
|
+
export function createStorageClient(): StorageClient {
|
|
44
|
+
const storageProvider = (process.env.STORAGE_PROVIDER || "supabase") as StorageProvider
|
|
45
|
+
|
|
46
|
+
if (storageProvider === "supabase") {
|
|
47
|
+
const url = process.env.SUPABASE_URL
|
|
48
|
+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
49
|
+
const bucket = process.env.SUPABASE_STORAGE_BUCKET || "uploads"
|
|
50
|
+
if (!url || !key) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required when STORAGE_PROVIDER=supabase. See .env.example."
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
return new SupabaseStorageClient(url, key, bucket)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(
|
|
59
|
+
`STORAGE_PROVIDER="${storageProvider}" is not implemented yet — only "supabase" is wired up. ` +
|
|
60
|
+
"See 13-TECH-STACK/DB_PROVIDER_GUIDE.md for how to add Cloudflare R2 or Vercel Blob."
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -6,18 +6,25 @@
|
|
|
6
6
|
"dev": "next dev",
|
|
7
7
|
"build": "next build",
|
|
8
8
|
"start": "next start",
|
|
9
|
-
"lint": "next lint"
|
|
9
|
+
"lint": "next lint",
|
|
10
|
+
"test": "vitest run"
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
13
|
+
"@auth/drizzle-adapter": "^1.7.4",
|
|
14
|
+
"@neondatabase/serverless": "^0.10.3",
|
|
12
15
|
"@radix-ui/react-avatar": "^1.1.0",
|
|
13
16
|
"@radix-ui/react-dialog": "^1.1.1",
|
|
14
17
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
|
15
18
|
"@radix-ui/react-slot": "^1.1.0",
|
|
16
19
|
"@radix-ui/react-tabs": "^1.1.0",
|
|
20
|
+
"@supabase/supabase-js": "^2.46.1",
|
|
17
21
|
"class-variance-authority": "^0.7.0",
|
|
18
22
|
"clsx": "^2.1.1",
|
|
23
|
+
"drizzle-orm": "^0.36.0",
|
|
19
24
|
"lucide-react": "^0.439.0",
|
|
20
25
|
"next": "^16.2.9",
|
|
26
|
+
"next-auth": "5.0.0-beta.25",
|
|
27
|
+
"postgres": "^3.4.5",
|
|
21
28
|
"react": "18.3.1",
|
|
22
29
|
"react-dom": "18.3.1",
|
|
23
30
|
"tailwind-merge": "^2.5.2"
|
|
@@ -27,10 +34,12 @@
|
|
|
27
34
|
"@types/react": "^18.3.4",
|
|
28
35
|
"@types/react-dom": "^18.3.0",
|
|
29
36
|
"autoprefixer": "^10.4.20",
|
|
37
|
+
"drizzle-kit": "^0.28.1",
|
|
30
38
|
"eslint": "^8.57.0",
|
|
31
39
|
"eslint-config-next": "14.2.5",
|
|
32
40
|
"postcss": "^8.4.41",
|
|
33
41
|
"tailwindcss": "^3.4.10",
|
|
34
|
-
"typescript": "^5.5.4"
|
|
42
|
+
"typescript": "^5.5.4",
|
|
43
|
+
"vitest": "^2.1.5"
|
|
35
44
|
}
|
|
36
45
|
}
|