Initial release v1.0.0

Open-source marketplace for AI Agent skills.

Features:
- Next.js 15 web app with i18n (en/fa)
- CLI tool for skill installation (npx skillhub)
- GitHub crawler/indexer with multi-strategy discovery
- Security scanning for all indexed skills
- Self-hostable with Docker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-12 06:08:51 +03:30
commit 97b427831a
227 changed files with 48411 additions and 0 deletions

58
packages/db/src/index.ts Normal file
View File

@@ -0,0 +1,58 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';
export * from './schema.js';
export * from './queries.js';
export * from './meilisearch.js';
// Re-export sql from drizzle-orm for use in API routes
export { sql } from 'drizzle-orm';
// Connection pool configuration
const POOL_CONFIG = {
max: 50, // Maximum connections in pool (increased for scalability)
idle_timeout: 30, // Close idle connections after 30 seconds
connect_timeout: 10, // Connection timeout in seconds
max_lifetime: 60 * 30, // Max connection lifetime (30 minutes)
};
// Singleton database instance (prevents connection exhaustion in serverless)
let dbInstance: ReturnType<typeof drizzle<typeof schema>> | null = null;
let clientInstance: ReturnType<typeof postgres> | null = null;
/**
* Create a database connection with connection pooling
* Uses singleton pattern to reuse connections across requests
*/
export function createDb(connectionString?: string) {
// Return existing instance if available (singleton)
if (dbInstance && !connectionString) {
return dbInstance;
}
const connString = connectionString || process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/skillhub';
// Create new client with pooling configuration
clientInstance = postgres(connString, POOL_CONFIG);
dbInstance = drizzle(clientInstance, { schema });
return dbInstance;
}
/**
* Close database connections gracefully
* Call this during graceful shutdown
*/
export async function closeDb(): Promise<void> {
if (clientInstance) {
await clientInstance.end();
clientInstance = null;
dbInstance = null;
}
}
/**
* Type for the database instance
*/
export type Database = ReturnType<typeof createDb>;

View File

@@ -0,0 +1,405 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
isMeilisearchConfigured,
getMeilisearchClient,
isMeilisearchHealthy,
initializeSkillsIndex,
addSkillDocument,
addSkillDocuments,
deleteSkillDocument,
searchSkills,
getAllSkillDocuments,
clearSkillsIndex,
type MeiliSkillDocument,
} from './meilisearch.js';
// Store original env
const originalEnv = { ...process.env };
// Mock MeiliSearch class
vi.mock('meilisearch', () => {
const mockIndex = {
search: vi.fn(),
addDocuments: vi.fn().mockResolvedValue({ taskUid: 1 }),
deleteDocument: vi.fn().mockResolvedValue({ taskUid: 2 }),
deleteAllDocuments: vi.fn().mockResolvedValue({ taskUid: 3 }),
getDocuments: vi.fn().mockResolvedValue({ results: [] }),
getSettings: vi.fn().mockResolvedValue({}),
updateSearchableAttributes: vi.fn().mockResolvedValue({ taskUid: 4 }),
updateFilterableAttributes: vi.fn().mockResolvedValue({ taskUid: 5 }),
updateSortableAttributes: vi.fn().mockResolvedValue({ taskUid: 6 }),
updateRankingRules: vi.fn().mockResolvedValue({ taskUid: 7 }),
};
return {
MeiliSearch: vi.fn().mockImplementation(() => ({
health: vi.fn().mockResolvedValue({ status: 'available' }),
index: vi.fn().mockReturnValue(mockIndex),
createIndex: vi.fn().mockResolvedValue({ taskUid: 0 }),
})),
Index: vi.fn(),
};
});
describe('Meilisearch Configuration', () => {
beforeEach(() => {
// Reset environment
delete process.env.MEILI_URL;
delete process.env.MEILI_MASTER_KEY;
vi.clearAllMocks();
});
afterEach(() => {
// Restore original env
process.env = { ...originalEnv };
});
describe('isMeilisearchConfigured', () => {
it('should return true when MEILI_URL is set', () => {
process.env.MEILI_URL = 'http://localhost:7700';
expect(isMeilisearchConfigured()).toBe(true);
});
it('should return false when MEILI_URL is not set', () => {
delete process.env.MEILI_URL;
expect(isMeilisearchConfigured()).toBe(false);
});
it('should return false when MEILI_URL is empty', () => {
process.env.MEILI_URL = '';
expect(isMeilisearchConfigured()).toBe(false);
});
});
describe('getMeilisearchClient', () => {
it('should return null when not configured', () => {
delete process.env.MEILI_URL;
const client = getMeilisearchClient();
expect(client).toBeNull();
});
it('should return client instance when configured', () => {
process.env.MEILI_URL = 'http://localhost:7700';
process.env.MEILI_MASTER_KEY = 'test-key';
const client = getMeilisearchClient();
expect(client).not.toBeNull();
});
it('should return same instance (singleton)', () => {
process.env.MEILI_URL = 'http://localhost:7700';
const client1 = getMeilisearchClient();
const client2 = getMeilisearchClient();
expect(client1).toBe(client2);
});
});
});
describe('Meilisearch Health', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('isMeilisearchHealthy', () => {
it('should return false when not configured', async () => {
delete process.env.MEILI_URL;
const result = await isMeilisearchHealthy();
expect(result).toBe(false);
});
it('should return true when Meilisearch is healthy', async () => {
process.env.MEILI_URL = 'http://localhost:7700';
const result = await isMeilisearchHealthy();
expect(result).toBe(true);
});
});
});
describe('Meilisearch Index Operations', () => {
beforeEach(() => {
process.env.MEILI_URL = 'http://localhost:7700';
process.env.MEILI_MASTER_KEY = 'test-key';
vi.clearAllMocks();
});
afterEach(() => {
delete process.env.MEILI_URL;
delete process.env.MEILI_MASTER_KEY;
});
describe('initializeSkillsIndex', () => {
it('should create index with correct settings', async () => {
await initializeSkillsIndex();
// The function should complete without errors
expect(true).toBe(true);
});
});
});
describe('Meilisearch Document Operations', () => {
const testSkillDocument: MeiliSkillDocument = {
id: 'test-owner/test-repo/test-skill',
name: 'test-skill',
description: 'A test skill',
githubOwner: 'test-owner',
githubRepo: 'test-repo',
platforms: ['claude', 'codex'],
githubStars: 100,
downloadCount: 50,
rating: 4,
securityScore: 85,
isFeatured: false,
isVerified: true,
indexedAt: new Date().toISOString(),
};
beforeEach(() => {
process.env.MEILI_URL = 'http://localhost:7700';
process.env.MEILI_MASTER_KEY = 'test-key';
vi.clearAllMocks();
});
afterEach(() => {
delete process.env.MEILI_URL;
delete process.env.MEILI_MASTER_KEY;
});
describe('addSkillDocument', () => {
it('should add document to index', async () => {
const result = await addSkillDocument(testSkillDocument);
expect(result).toBe(true);
});
it('should return false when not configured', async () => {
delete process.env.MEILI_URL;
const result = await addSkillDocument(testSkillDocument);
expect(result).toBe(false);
});
});
describe('addSkillDocuments', () => {
it('should add multiple documents', async () => {
const skills = [
testSkillDocument,
{ ...testSkillDocument, id: 'another/skill/test' },
];
const result = await addSkillDocuments(skills);
expect(result).toBe(true);
});
it('should return true for empty array', async () => {
const result = await addSkillDocuments([]);
expect(result).toBe(true);
});
it('should return false when not configured', async () => {
delete process.env.MEILI_URL;
const result = await addSkillDocuments([testSkillDocument]);
expect(result).toBe(false);
});
});
describe('deleteSkillDocument', () => {
it('should delete document from index', async () => {
const result = await deleteSkillDocument('test-owner/test-repo/test-skill');
expect(result).toBe(true);
});
it('should return false when not configured', async () => {
delete process.env.MEILI_URL;
const result = await deleteSkillDocument('test-owner/test-repo/test-skill');
expect(result).toBe(false);
});
});
describe('getAllSkillDocuments', () => {
it('should return all documents', async () => {
const result = await getAllSkillDocuments();
expect(Array.isArray(result)).toBe(true);
});
it('should return empty array when not configured', async () => {
delete process.env.MEILI_URL;
const result = await getAllSkillDocuments();
expect(result).toEqual([]);
});
});
describe('clearSkillsIndex', () => {
it('should clear all documents', async () => {
const result = await clearSkillsIndex();
expect(result).toBe(true);
});
it('should return false when not configured', async () => {
delete process.env.MEILI_URL;
const result = await clearSkillsIndex();
expect(result).toBe(false);
});
});
});
describe('Meilisearch Search', () => {
beforeEach(() => {
process.env.MEILI_URL = 'http://localhost:7700';
process.env.MEILI_MASTER_KEY = 'test-key';
vi.clearAllMocks();
});
afterEach(() => {
delete process.env.MEILI_URL;
delete process.env.MEILI_MASTER_KEY;
});
describe('searchSkills', () => {
it('should search with query', async () => {
// Mock search response
const { MeiliSearch } = await import('meilisearch');
const mockClient = new MeiliSearch({ host: 'http://localhost:7700' });
const mockIndex = mockClient.index('skills');
vi.mocked(mockIndex.search).mockResolvedValue({
hits: [],
estimatedTotalHits: 0,
processingTimeMs: 1,
query: 'test',
limit: 20,
offset: 0,
});
const result = await searchSkills({ query: 'test' });
// Result could be null if index doesn't exist
expect(result === null || typeof result === 'object').toBe(true);
});
it('should return null when not configured', async () => {
delete process.env.MEILI_URL;
const result = await searchSkills({ query: 'test' });
expect(result).toBeNull();
});
it('should apply platform filter', async () => {
const result = await searchSkills({
query: 'test',
filters: { platforms: ['claude'] },
});
// Just verify it doesn't throw
expect(result === null || typeof result === 'object').toBe(true);
});
it('should apply star filter', async () => {
const result = await searchSkills({
query: 'test',
filters: { minStars: 100 },
});
expect(result === null || typeof result === 'object').toBe(true);
});
it('should apply verified filter', async () => {
const result = await searchSkills({
query: 'test',
filters: { verified: true },
});
expect(result === null || typeof result === 'object').toBe(true);
});
it('should apply featured filter', async () => {
const result = await searchSkills({
query: 'test',
filters: { featured: true },
});
expect(result === null || typeof result === 'object').toBe(true);
});
it('should apply security score filter', async () => {
const result = await searchSkills({
query: 'test',
filters: { minSecurity: 80 },
});
expect(result === null || typeof result === 'object').toBe(true);
});
it('should sort by stars', async () => {
const result = await searchSkills({
query: 'test',
sort: 'stars',
});
expect(result === null || typeof result === 'object').toBe(true);
});
it('should sort by downloads', async () => {
const result = await searchSkills({
query: 'test',
sort: 'downloads',
});
expect(result === null || typeof result === 'object').toBe(true);
});
it('should sort by rating', async () => {
const result = await searchSkills({
query: 'test',
sort: 'rating',
});
expect(result === null || typeof result === 'object').toBe(true);
});
it('should sort by recent', async () => {
const result = await searchSkills({
query: 'test',
sort: 'recent',
});
expect(result === null || typeof result === 'object').toBe(true);
});
it('should respect pagination', async () => {
const result = await searchSkills({
query: 'test',
limit: 10,
offset: 20,
});
expect(result === null || typeof result === 'object').toBe(true);
});
});
});

View File

@@ -0,0 +1,332 @@
import { MeiliSearch } from 'meilisearch';
import type { Index, SearchResponse } from 'meilisearch';
/**
* Meilisearch client module for SkillHub
*
* This module is OPTIONAL - if MEILI_URL is not set, search falls back to PostgreSQL.
* This allows developers to run the project without Meilisearch for simpler setup.
*/
const SKILLS_INDEX = 'skills';
/**
* Meilisearch document type for skills
*/
export interface MeiliSkillDocument {
id: string;
name: string;
description: string;
githubOwner: string;
githubRepo: string;
platforms: string[];
githubStars: number;
downloadCount: number;
rating: number;
ratingCount: number;
securityScore: number;
securityStatus: 'pass' | 'warning' | 'fail' | null;
isFeatured: boolean;
isVerified: boolean;
indexedAt: string;
}
/**
* Search options for Meilisearch
*/
export interface MeiliSearchOptions {
query: string;
filters?: {
platforms?: string[];
minStars?: number;
minSecurity?: number;
verified?: boolean;
featured?: boolean;
};
sort?: 'stars' | 'downloads' | 'rating' | 'recent';
limit?: number;
offset?: number;
}
/**
* Search result type
*/
export interface MeiliSearchResult {
hits: MeiliSkillDocument[];
estimatedTotalHits: number;
processingTimeMs: number;
}
// Singleton client instance
let client: MeiliSearch | null = null;
/**
* Check if Meilisearch is configured
*/
export function isMeilisearchConfigured(): boolean {
return Boolean(process.env.MEILI_URL);
}
/**
* Get or create the Meilisearch client
* Returns null if MEILI_URL is not configured
*/
export function getMeilisearchClient(): MeiliSearch | null {
if (!isMeilisearchConfigured()) {
return null;
}
if (!client) {
client = new MeiliSearch({
host: process.env.MEILI_URL!,
apiKey: process.env.MEILI_MASTER_KEY,
});
}
return client;
}
/**
* Check if Meilisearch is healthy and accessible
*/
export async function isMeilisearchHealthy(): Promise<boolean> {
const meili = getMeilisearchClient();
if (!meili) return false;
try {
await meili.health();
return true;
} catch {
return false;
}
}
/**
* Get or create the skills index with proper settings
*/
export async function getSkillsIndex(): Promise<Index<MeiliSkillDocument> | null> {
const meili = getMeilisearchClient();
if (!meili) return null;
try {
const index = meili.index<MeiliSkillDocument>(SKILLS_INDEX);
// Check if index exists by trying to get its settings
try {
await index.getSettings();
} catch {
// Index doesn't exist, create it with settings
await initializeSkillsIndex();
}
return index;
} catch (error) {
console.error('Failed to get skills index:', error);
return null;
}
}
/**
* Initialize the skills index with proper settings
*/
export async function initializeSkillsIndex(): Promise<void> {
const meili = getMeilisearchClient();
if (!meili) return;
try {
// Create index if not exists
await meili.createIndex(SKILLS_INDEX, { primaryKey: 'id' });
const index = meili.index(SKILLS_INDEX);
// Configure searchable attributes (order matters for ranking)
await index.updateSearchableAttributes([
'name',
'description',
'githubOwner',
'githubRepo',
]);
// Configure filterable attributes
await index.updateFilterableAttributes([
'platforms',
'isVerified',
'isFeatured',
'securityScore',
'githubStars',
]);
// Configure sortable attributes
await index.updateSortableAttributes([
'githubStars',
'downloadCount',
'rating',
'indexedAt',
]);
// Configure ranking rules (relevance + custom)
await index.updateRankingRules([
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
'githubStars:desc', // Boost popular skills
]);
console.log('Meilisearch skills index initialized');
} catch (error) {
console.error('Failed to initialize skills index:', error);
}
}
/**
* Add or update a skill document in Meilisearch
*/
export async function addSkillDocument(skill: MeiliSkillDocument): Promise<boolean> {
const index = await getSkillsIndex();
if (!index) return false;
try {
await index.addDocuments([skill]);
return true;
} catch (error) {
console.error('Failed to add skill to Meilisearch:', error);
return false;
}
}
/**
* Add or update multiple skill documents in Meilisearch
*/
export async function addSkillDocuments(skills: MeiliSkillDocument[]): Promise<boolean> {
if (skills.length === 0) return true;
const index = await getSkillsIndex();
if (!index) return false;
try {
await index.addDocuments(skills);
return true;
} catch (error) {
console.error('Failed to add skills to Meilisearch:', error);
return false;
}
}
/**
* Delete a skill document from Meilisearch
*/
export async function deleteSkillDocument(skillId: string): Promise<boolean> {
const index = await getSkillsIndex();
if (!index) return false;
try {
await index.deleteDocument(skillId);
return true;
} catch (error) {
console.error('Failed to delete skill from Meilisearch:', error);
return false;
}
}
/**
* Search skills using Meilisearch
*/
export async function searchSkills(options: MeiliSearchOptions): Promise<MeiliSearchResult | null> {
const index = await getSkillsIndex();
if (!index) return null;
try {
// Build filter string
const filterParts: string[] = [];
if (options.filters?.platforms?.length) {
// Filter by any of the platforms
const platformFilters = options.filters.platforms.map(p => `platforms = "${p}"`);
filterParts.push(`(${platformFilters.join(' OR ')})`);
}
if (options.filters?.verified) {
filterParts.push('isVerified = true');
}
if (options.filters?.featured) {
filterParts.push('isFeatured = true');
}
if (options.filters?.minStars) {
filterParts.push(`githubStars >= ${options.filters.minStars}`);
}
if (options.filters?.minSecurity) {
filterParts.push(`securityScore >= ${options.filters.minSecurity}`);
}
// Build sort array
const sort: string[] = [];
switch (options.sort) {
case 'stars':
sort.push('githubStars:desc');
break;
case 'downloads':
sort.push('downloadCount:desc');
break;
case 'rating':
sort.push('rating:desc');
break;
case 'recent':
sort.push('indexedAt:desc');
break;
}
const searchResult: SearchResponse<MeiliSkillDocument> = await index.search(options.query, {
filter: filterParts.length > 0 ? filterParts.join(' AND ') : undefined,
sort: sort.length > 0 ? sort : undefined,
limit: options.limit || 20,
offset: options.offset || 0,
});
return {
hits: searchResult.hits,
estimatedTotalHits: searchResult.estimatedTotalHits || searchResult.hits.length,
processingTimeMs: searchResult.processingTimeMs,
};
} catch (error) {
console.error('Meilisearch search failed:', error);
return null;
}
}
/**
* Get all documents from the skills index (for debugging/admin)
*/
export async function getAllSkillDocuments(limit = 1000): Promise<MeiliSkillDocument[]> {
const index = await getSkillsIndex();
if (!index) return [];
try {
const result = await index.getDocuments({ limit });
return result.results;
} catch (error) {
console.error('Failed to get all skill documents:', error);
return [];
}
}
/**
* Clear all documents from the skills index
*/
export async function clearSkillsIndex(): Promise<boolean> {
const index = await getSkillsIndex();
if (!index) return false;
try {
await index.deleteAllDocuments();
return true;
} catch (error) {
console.error('Failed to clear skills index:', error);
return false;
}
}

View File

@@ -0,0 +1,661 @@
import { describe, it, expect, vi } from 'vitest';
import {
skillQueries,
categoryQueries,
userQueries,
ratingQueries,
installationQueries,
favoriteQueries,
} from './queries.js';
import {
createTestSkill,
createTestCategory,
createTestUser,
createTestRating,
createTestFavorite,
} from './test-utils.js';
/**
* These tests use mocked database operations to test the query logic.
* For integration tests with a real database, run with: pnpm test:integration
*/
// Mock the schema imports
vi.mock('./schema.js', () => ({
skills: { id: 'id', name: 'name', description: 'description', githubStars: 'github_stars', downloadCount: 'download_count', rating: 'rating', updatedAt: 'updated_at', isFeatured: 'is_featured', isVerified: 'is_verified', securityScore: 'security_score', viewCount: 'view_count', ratingCount: 'rating_count', ratingSum: 'rating_sum' },
categories: { id: 'id', name: 'name', slug: 'slug', sortOrder: 'sort_order', skillCount: 'skill_count' },
skillCategories: { skillId: 'skill_id', categoryId: 'category_id' },
users: { id: 'id', githubId: 'github_id', username: 'username', avatarUrl: 'avatar_url' },
ratings: { id: 'id', skillId: 'skill_id', userId: 'user_id', rating: 'rating', createdAt: 'created_at' },
installations: { id: 'id', skillId: 'skill_id', platform: 'platform', method: 'method' },
favorites: { userId: 'user_id', skillId: 'skill_id', createdAt: 'created_at' },
}));
// Helper to create a chainable mock
function createChainableMock(result: unknown = []) {
const mock = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockReturnThis(),
onConflictDoNothing: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue(Array.isArray(result) ? result : [result]),
then: (resolve: (v: unknown) => void) => Promise.resolve(result).then(resolve),
[Symbol.toStringTag]: 'Promise',
};
// Make it thenable
Object.defineProperty(mock, 'then', {
value: (resolve: (v: unknown) => void) => Promise.resolve(result).then(resolve),
enumerable: false,
});
return mock;
}
function createMockDb(defaultResult: unknown = []) {
return {
select: vi.fn().mockReturnValue(createChainableMock(defaultResult)),
insert: vi.fn().mockReturnValue(createChainableMock(defaultResult)),
update: vi.fn().mockReturnValue(createChainableMock(defaultResult)),
delete: vi.fn().mockReturnValue(createChainableMock(defaultResult)),
};
}
describe('skillQueries', () => {
describe('getById', () => {
it('should return skill when found', async () => {
const skill = createTestSkill();
const mockDb = createMockDb([skill]);
const result = await skillQueries.getById(mockDb as any, 'test-owner/test-repo/test-skill');
expect(result).toEqual(skill);
expect(mockDb.select).toHaveBeenCalled();
});
it('should return null when skill not found', async () => {
const mockDb = createMockDb([]);
const result = await skillQueries.getById(mockDb as any, 'nonexistent');
expect(result).toBeNull();
});
});
describe('search', () => {
it('should return skills with default options', async () => {
const skills = [createTestSkill(), createTestSkill({ id: 'another/skill/test' })];
const mockDb = createMockDb(skills);
const result = await skillQueries.search(mockDb as any, {});
expect(result).toEqual(skills);
expect(mockDb.select).toHaveBeenCalled();
});
it('should apply query filter', async () => {
const mockDb = createMockDb([]);
await skillQueries.search(mockDb as any, { query: 'test' });
expect(mockDb.select).toHaveBeenCalled();
});
it('should apply minStars filter', async () => {
const mockDb = createMockDb([]);
await skillQueries.search(mockDb as any, { minStars: 100 });
expect(mockDb.select).toHaveBeenCalled();
});
it('should apply minSecurity filter', async () => {
const mockDb = createMockDb([]);
await skillQueries.search(mockDb as any, { minSecurity: 80 });
expect(mockDb.select).toHaveBeenCalled();
});
it('should apply verified filter', async () => {
const mockDb = createMockDb([]);
await skillQueries.search(mockDb as any, { verified: true });
expect(mockDb.select).toHaveBeenCalled();
});
it('should respect limit and offset', async () => {
const mockDb = createMockDb([]);
await skillQueries.search(mockDb as any, { limit: 10, offset: 20 });
expect(mockDb.select).toHaveBeenCalled();
});
it('should sort by stars descending by default', async () => {
const mockDb = createMockDb([]);
await skillQueries.search(mockDb as any, {});
expect(mockDb.select).toHaveBeenCalled();
});
it('should sort by downloads when specified', async () => {
const mockDb = createMockDb([]);
await skillQueries.search(mockDb as any, { sortBy: 'downloads' });
expect(mockDb.select).toHaveBeenCalled();
});
it('should sort ascending when specified', async () => {
const mockDb = createMockDb([]);
await skillQueries.search(mockDb as any, { sortBy: 'stars', sortOrder: 'asc' });
expect(mockDb.select).toHaveBeenCalled();
});
});
describe('getFeatured', () => {
it('should return featured skills', async () => {
const featuredSkills = [
createTestSkill({ isFeatured: true }),
createTestSkill({ id: 'another/featured/skill', isFeatured: true }),
];
const mockDb = createMockDb(featuredSkills);
const result = await skillQueries.getFeatured(mockDb as any, 10);
expect(result).toEqual(featuredSkills);
expect(mockDb.select).toHaveBeenCalled();
});
it('should respect limit parameter', async () => {
const mockDb = createMockDb([]);
await skillQueries.getFeatured(mockDb as any, 5);
expect(mockDb.select).toHaveBeenCalled();
});
});
describe('getTrending', () => {
it('should return skills ordered by downloads', async () => {
const trendingSkills = [
createTestSkill({ downloadCount: 1000 }),
createTestSkill({ id: 'another/skill/test', downloadCount: 500 }),
];
const mockDb = createMockDb(trendingSkills);
const result = await skillQueries.getTrending(mockDb as any, 10);
expect(result).toEqual(trendingSkills);
});
});
describe('getRecent', () => {
it('should return skills ordered by updatedAt', async () => {
const recentSkills = [
createTestSkill({ updatedAt: new Date('2024-01-02') }),
createTestSkill({ id: 'another/skill/test', updatedAt: new Date('2024-01-01') }),
];
const mockDb = createMockDb(recentSkills);
const result = await skillQueries.getRecent(mockDb as any, 10);
expect(result).toEqual(recentSkills);
});
});
describe('upsert', () => {
it('should insert new skill', async () => {
const skill = createTestSkill();
const mockDb = createMockDb([skill]);
const result = await skillQueries.upsert(mockDb as any, skill);
expect(result).toEqual(skill);
expect(mockDb.insert).toHaveBeenCalled();
});
it('should update existing skill on conflict', async () => {
const skill = createTestSkill();
const updatedSkill = { ...skill, description: 'Updated description' };
const mockDb = createMockDb([updatedSkill]);
const result = await skillQueries.upsert(mockDb as any, updatedSkill);
expect(result.description).toBe('Updated description');
});
});
describe('incrementDownloads', () => {
it('should increment download count', async () => {
const mockDb = createMockDb();
await skillQueries.incrementDownloads(mockDb as any, 'test-owner/test-repo/test-skill');
expect(mockDb.update).toHaveBeenCalled();
});
});
describe('incrementViews', () => {
it('should increment view count', async () => {
const mockDb = createMockDb();
await skillQueries.incrementViews(mockDb as any, 'test-owner/test-repo/test-skill');
expect(mockDb.update).toHaveBeenCalled();
});
});
describe('updateRating', () => {
it('should update skill rating aggregates', async () => {
const mockDb = {
select: vi.fn().mockReturnValue(createChainableMock([{ count: 5, sum: 20, avg: 4 }])),
update: vi.fn().mockReturnValue(createChainableMock()),
};
await skillQueries.updateRating(mockDb as any, 'test-owner/test-repo/test-skill');
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.update).toHaveBeenCalled();
});
});
});
describe('categoryQueries', () => {
describe('getAll', () => {
it('should return all categories', async () => {
const categories = [
createTestCategory({ sortOrder: 1 }),
createTestCategory({ id: 'cat-2', sortOrder: 2 }),
];
const mockDb = createMockDb(categories);
const result = await categoryQueries.getAll(mockDb as any);
expect(result).toEqual(categories);
});
it('should order by sortOrder then name', async () => {
const mockDb = createMockDb([]);
await categoryQueries.getAll(mockDb as any);
expect(mockDb.select).toHaveBeenCalled();
});
});
describe('getBySlug', () => {
it('should return category when found', async () => {
const category = createTestCategory({ slug: 'test-category' });
const mockDb = createMockDb([category]);
const result = await categoryQueries.getBySlug(mockDb as any, 'test-category');
expect(result).toEqual(category);
});
it('should return null when category not found', async () => {
const mockDb = createMockDb([]);
const result = await categoryQueries.getBySlug(mockDb as any, 'nonexistent');
expect(result).toBeNull();
});
});
describe('getSkills', () => {
it('should return skills in category', async () => {
const skills = [
{ skill: createTestSkill() },
{ skill: createTestSkill({ id: 'another/skill/test' }) },
];
const mockDb = createMockDb(skills);
const result = await categoryQueries.getSkills(mockDb as any, 'cat-1', 20, 0);
expect(result).toEqual(skills);
});
it('should respect pagination', async () => {
const mockDb = createMockDb([]);
await categoryQueries.getSkills(mockDb as any, 'cat-1', 10, 5);
expect(mockDb.select).toHaveBeenCalled();
});
});
// NOTE: updateSkillCount test removed - function deleted because database trigger handles counts automatically
// (See init-db.sql lines 356-373: update_category_count trigger)
});
describe('userQueries', () => {
describe('getByGithubId', () => {
it('should return user when found', async () => {
const user = createTestUser({ githubId: 'gh-12345' });
const mockDb = createMockDb([user]);
const result = await userQueries.getByGithubId(mockDb as any, 'gh-12345');
expect(result).toEqual(user);
});
it('should return null when user not found', async () => {
const mockDb = createMockDb([]);
const result = await userQueries.getByGithubId(mockDb as any, 'nonexistent');
expect(result).toBeNull();
});
});
describe('upsertFromGithub', () => {
it('should insert new user from GitHub OAuth', async () => {
const userData = {
githubId: 'gh-12345',
username: 'testuser',
displayName: 'Test User',
email: 'test@example.com',
avatarUrl: 'https://example.com/avatar.png',
};
const user = createTestUser(userData);
const mockDb = createMockDb([user]);
const result = await userQueries.upsertFromGithub(mockDb as any, userData);
expect(result).toEqual(user);
expect(mockDb.insert).toHaveBeenCalled();
});
it('should update existing user on conflict', async () => {
const userData = {
githubId: 'gh-12345',
username: 'updateduser',
};
const mockDb = createMockDb([createTestUser(userData)]);
const result = await userQueries.upsertFromGithub(mockDb as any, userData);
expect(result.username).toBe('updateduser');
});
});
describe('getFavorites', () => {
it('should return user favorites with skill details', async () => {
const favorites = [
{ skill: createTestSkill() },
{ skill: createTestSkill({ id: 'another/skill/test' }) },
];
const mockDb = createMockDb(favorites);
const result = await userQueries.getFavorites(mockDb as any, 'user-1');
expect(result).toEqual(favorites);
});
});
describe('getById', () => {
it('should return user by database ID', async () => {
const user = createTestUser({ id: 'user-123' });
const mockDb = createMockDb([user]);
const result = await userQueries.getById(mockDb as any, 'user-123');
expect(result).toEqual(user);
});
it('should return null when user not found', async () => {
const mockDb = createMockDb([]);
const result = await userQueries.getById(mockDb as any, 'nonexistent');
expect(result).toBeNull();
});
});
});
describe('ratingQueries', () => {
describe('upsert', () => {
it('should insert new rating', async () => {
const ratingData = {
skillId: 'test-owner/test-repo/test-skill',
userId: 'user-1',
rating: 5,
review: 'Excellent skill!',
};
const rating = createTestRating(ratingData);
// Mock both insert and the updateRating call
const mockDb = {
insert: vi.fn().mockReturnValue(createChainableMock([rating])),
select: vi.fn().mockReturnValue(createChainableMock([{ count: 1, sum: 5, avg: 5 }])),
update: vi.fn().mockReturnValue(createChainableMock()),
};
const result = await ratingQueries.upsert(mockDb as any, ratingData);
expect(result).toEqual(rating);
expect(mockDb.insert).toHaveBeenCalled();
});
it('should update existing rating on conflict', async () => {
const ratingData = {
skillId: 'test-owner/test-repo/test-skill',
userId: 'user-1',
rating: 4,
};
const mockDb = {
insert: vi.fn().mockReturnValue(createChainableMock([createTestRating(ratingData)])),
select: vi.fn().mockReturnValue(createChainableMock([{ count: 1, sum: 4, avg: 4 }])),
update: vi.fn().mockReturnValue(createChainableMock()),
};
const result = await ratingQueries.upsert(mockDb as any, ratingData);
expect(result.rating).toBe(4);
});
});
describe('getForSkill', () => {
it('should return ratings with user info', async () => {
const ratings = [
{
rating: createTestRating(),
user: { id: 'user-1', username: 'testuser', avatarUrl: 'https://example.com/avatar.png' },
},
];
const mockDb = createMockDb(ratings);
const result = await ratingQueries.getForSkill(mockDb as any, 'test-owner/test-repo/test-skill');
expect(result).toEqual(ratings);
});
it('should respect pagination', async () => {
const mockDb = createMockDb([]);
await ratingQueries.getForSkill(mockDb as any, 'test-owner/test-repo/test-skill', 5, 10);
expect(mockDb.select).toHaveBeenCalled();
});
});
describe('getUserRating', () => {
it('should return user rating for skill', async () => {
const rating = createTestRating();
const mockDb = createMockDb([rating]);
const result = await ratingQueries.getUserRating(mockDb as any, 'user-1', 'test-owner/test-repo/test-skill');
expect(result).toEqual(rating);
});
it('should return null if not rated', async () => {
const mockDb = createMockDb([]);
const result = await ratingQueries.getUserRating(mockDb as any, 'user-1', 'test-owner/test-repo/test-skill');
expect(result).toBeNull();
});
});
});
describe('installationQueries', () => {
describe('track', () => {
it('should create installation record', async () => {
const mockDb = {
insert: vi.fn().mockReturnValue(createChainableMock()),
update: vi.fn().mockReturnValue(createChainableMock()),
};
await installationQueries.track(mockDb as any, 'test-owner/test-repo/test-skill', 'claude', 'cli');
expect(mockDb.insert).toHaveBeenCalled();
});
it('should increment skill downloads', async () => {
const mockDb = {
insert: vi.fn().mockReturnValue(createChainableMock()),
update: vi.fn().mockReturnValue(createChainableMock()),
};
await installationQueries.track(mockDb as any, 'test-owner/test-repo/test-skill', 'codex', 'web');
expect(mockDb.update).toHaveBeenCalled();
});
});
describe('getStats', () => {
it('should group by platform', async () => {
const stats = [
{ platform: 'claude', count: 50 },
{ platform: 'codex', count: 30 },
{ platform: 'copilot', count: 20 },
];
const mockDb = createMockDb(stats);
const result = await installationQueries.getStats(mockDb as any, 'test-owner/test-repo/test-skill');
expect(result).toEqual(stats);
});
it('should return counts per platform', async () => {
const stats = [{ platform: 'claude', count: 100 }];
const mockDb = createMockDb(stats);
const result = await installationQueries.getStats(mockDb as any, 'test-owner/test-repo/test-skill');
expect(result[0].count).toBe(100);
});
});
});
describe('favoriteQueries', () => {
describe('add', () => {
it('should add favorite', async () => {
const mockDb = createMockDb();
await favoriteQueries.add(mockDb as any, 'user-1', 'test-owner/test-repo/test-skill');
expect(mockDb.insert).toHaveBeenCalled();
});
it('should not duplicate on conflict', async () => {
const mockDb = createMockDb();
await favoriteQueries.add(mockDb as any, 'user-1', 'test-owner/test-repo/test-skill');
// onConflictDoNothing should be called
expect(mockDb.insert).toHaveBeenCalled();
});
});
describe('remove', () => {
it('should remove favorite', async () => {
const mockDb = createMockDb();
await favoriteQueries.remove(mockDb as any, 'user-1', 'test-owner/test-repo/test-skill');
expect(mockDb.delete).toHaveBeenCalled();
});
it('should handle non-existent favorite gracefully', async () => {
const mockDb = createMockDb();
// Should not throw
await expect(
favoriteQueries.remove(mockDb as any, 'user-1', 'nonexistent')
).resolves.not.toThrow();
});
});
describe('isFavorited', () => {
it('should return true when favorited', async () => {
const favorite = createTestFavorite();
const mockDb = createMockDb([favorite]);
const result = await favoriteQueries.isFavorited(mockDb as any, 'user-1', 'test-owner/test-repo/test-skill');
expect(result).toBe(true);
});
it('should return false when not favorited', async () => {
const mockDb = createMockDb([]);
const result = await favoriteQueries.isFavorited(mockDb as any, 'user-1', 'test-owner/test-repo/test-skill');
expect(result).toBe(false);
});
});
describe('getFavoritedIds', () => {
it('should return favorited skill IDs', async () => {
const favorites = [
{ skillId: 'skill-1' },
{ skillId: 'skill-2' },
];
const mockDb = createMockDb(favorites);
const result = await favoriteQueries.getFavoritedIds(
mockDb as any,
'user-1',
['skill-1', 'skill-2', 'skill-3']
);
expect(result).toEqual(['skill-1', 'skill-2']);
});
it('should return empty array for empty input', async () => {
const mockDb = createMockDb([]);
const result = await favoriteQueries.getFavoritedIds(mockDb as any, 'user-1', []);
expect(result).toEqual([]);
});
it('should return empty array when none favorited', async () => {
const mockDb = createMockDb([]);
const result = await favoriteQueries.getFavoritedIds(
mockDb as any,
'user-1',
['skill-1', 'skill-2']
);
expect(result).toEqual([]);
});
});
});

2010
packages/db/src/queries.ts Normal file

File diff suppressed because it is too large Load Diff

472
packages/db/src/schema.ts Normal file
View File

@@ -0,0 +1,472 @@
import {
pgTable,
text,
timestamp,
integer,
jsonb,
boolean,
index,
primaryKey,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
/**
* Skills table - main entity storing indexed skills
*/
export const skills = pgTable(
'skills',
{
// Primary key: owner/repo/skill-name
id: text('id').primaryKey(),
name: text('name').notNull(),
description: text('description').notNull(),
// Source information
githubOwner: text('github_owner').notNull(),
githubRepo: text('github_repo').notNull(),
skillPath: text('skill_path').notNull(),
branch: text('branch').default('main'),
commitSha: text('commit_sha'),
// Source format (which platform's instruction file format)
sourceFormat: text('source_format').default('skill.md'),
// Metadata
version: text('version'),
license: text('license'),
author: text('author'),
homepage: text('homepage'),
compatibility: jsonb('compatibility').$type<{
platforms?: string[];
requires?: string[];
minVersion?: string;
}>(),
triggers: jsonb('triggers').$type<{
filePatterns?: string[];
keywords?: string[];
languages?: string[];
}>(),
// Quality signals
githubStars: integer('github_stars').default(0),
githubForks: integer('github_forks').default(0),
downloadCount: integer('download_count').default(0),
viewCount: integer('view_count').default(0),
// Ratings
rating: integer('rating'), // 1-5 average
ratingCount: integer('rating_count').default(0),
ratingSum: integer('rating_sum').default(0),
// Security
securityScore: integer('security_score'), // 0-100 (deprecated, use securityStatus)
securityStatus: text('security_status').$type<'pass' | 'warning' | 'fail'>(), // PASS, WARNING, FAIL
isVerified: boolean('is_verified').default(false),
isFeatured: boolean('is_featured').default(false),
isBlocked: boolean('is_blocked').default(false), // Blocked from re-indexing (owner requested removal)
lastScanned: timestamp('last_scanned'),
// Content (cached)
contentHash: text('content_hash'),
rawContent: text('raw_content'),
// Cached skill files (populated on first download)
cachedFiles: jsonb('cached_files').$type<{
fetchedAt: string; // ISO timestamp when files were fetched
commitSha: string; // Git commit SHA for cache invalidation
totalSize: number; // Total size in bytes
items: Array<{
name: string; // e.g., "SKILL.md", "setup.sh"
path: string; // Relative path from skill root
content: string; // File content (base64 for binary)
size: number; // File size in bytes
isBinary: boolean; // Whether content is base64 encoded
}>;
}>(),
// Timestamps
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
indexedAt: timestamp('indexed_at'),
lastDownloadedAt: timestamp('last_downloaded_at'),
},
(table) => ({
nameIdx: index('idx_skills_name').on(table.name),
ownerIdx: index('idx_skills_owner').on(table.githubOwner),
starsIdx: index('idx_skills_stars').on(table.githubStars),
securityIdx: index('idx_skills_security').on(table.securityScore),
securityStatusIdx: index('idx_skills_security_status').on(table.securityStatus),
verifiedIdx: index('idx_skills_verified').on(table.isVerified),
featuredIdx: index('idx_skills_featured').on(table.isFeatured),
blockedIdx: index('idx_skills_blocked').on(table.isBlocked),
updatedIdx: index('idx_skills_updated').on(table.updatedAt),
sourceFormatIdx: index('idx_skills_source_format').on(table.sourceFormat),
lastDownloadedIdx: index('idx_skills_last_downloaded').on(table.lastDownloadedAt),
})
);
/**
* Categories for organizing skills
*/
export const categories = pgTable(
'categories',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
description: text('description'),
icon: text('icon'),
color: text('color'),
parentId: text('parent_id'),
sortOrder: integer('sort_order').default(0),
skillCount: integer('skill_count').default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
slugIdx: uniqueIndex('idx_categories_slug').on(table.slug),
})
);
/**
* Many-to-many relationship between skills and categories
*/
export const skillCategories = pgTable(
'skill_categories',
{
skillId: text('skill_id')
.references(() => skills.id, { onDelete: 'cascade' })
.notNull(),
categoryId: text('category_id')
.references(() => categories.id, { onDelete: 'cascade' })
.notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.skillId, table.categoryId] }),
skillIdx: index('idx_skill_categories_skill').on(table.skillId),
categoryIdx: index('idx_skill_categories_category').on(table.categoryId),
})
);
/**
* Users (authenticated via GitHub OAuth)
*/
export const users = pgTable(
'users',
{
id: text('id').primaryKey(),
githubId: text('github_id').unique().notNull(),
username: text('username').notNull(),
displayName: text('display_name'),
email: text('email'),
avatarUrl: text('avatar_url'),
bio: text('bio'),
preferredLocale: text('preferred_locale'),
isAdmin: boolean('is_admin').default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
lastLoginAt: timestamp('last_login_at'),
},
(table) => ({
githubIdx: uniqueIndex('idx_users_github').on(table.githubId),
usernameIdx: index('idx_users_username').on(table.username),
})
);
/**
* User ratings and reviews for skills
*/
export const ratings = pgTable(
'ratings',
{
id: text('id').primaryKey(),
skillId: text('skill_id')
.references(() => skills.id, { onDelete: 'cascade' })
.notNull(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
rating: integer('rating').notNull(), // 1-5
review: text('review'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
skillIdx: index('idx_ratings_skill').on(table.skillId),
userIdx: index('idx_ratings_user').on(table.userId),
userSkillIdx: uniqueIndex('idx_ratings_user_skill').on(table.userId, table.skillId),
})
);
/**
* Anonymous installation tracking
*/
export const installations = pgTable(
'installations',
{
id: text('id').primaryKey(),
skillId: text('skill_id')
.references(() => skills.id, { onDelete: 'cascade' })
.notNull(),
platform: text('platform').notNull(), // claude, codex, copilot
method: text('method'), // cli, web, desktop
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
skillIdx: index('idx_installations_skill').on(table.skillId),
platformIdx: index('idx_installations_platform').on(table.platform),
createdIdx: index('idx_installations_created').on(table.createdAt),
})
);
/**
* User favorites/bookmarks
*/
export const favorites = pgTable(
'favorites',
{
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
skillId: text('skill_id')
.references(() => skills.id, { onDelete: 'cascade' })
.notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.skillId] }),
userIdx: index('idx_favorites_user').on(table.userId),
skillIdx: index('idx_favorites_skill').on(table.skillId),
})
);
/**
* Indexing job queue status
*/
export const indexingJobs = pgTable(
'indexing_jobs',
{
id: text('id').primaryKey(),
type: text('type').notNull(), // full-crawl, incremental, single-skill
status: text('status').notNull(), // pending, running, completed, failed
skillId: text('skill_id'),
startedAt: timestamp('started_at'),
completedAt: timestamp('completed_at'),
error: text('error'),
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
statusIdx: index('idx_indexing_jobs_status').on(table.status),
typeIdx: index('idx_indexing_jobs_type').on(table.type),
})
);
/**
* Discovered repositories - tracks repos found by various discovery strategies
* Used to queue repos for deep scanning and track discovery sources
*/
export const discoveredRepos = pgTable(
'discovered_repos',
{
id: text('id').primaryKey(), // owner/repo
owner: text('owner').notNull(),
repo: text('repo').notNull(),
discoveredVia: text('discovered_via').notNull(), // 'awesome-list', 'topic-search', 'fork', 'org-scan', 'code-search'
sourceUrl: text('source_url'), // URL of the awesome list or search query that found this repo
lastScanned: timestamp('last_scanned'),
skillCount: integer('skill_count').default(0),
hasSkillMd: boolean('has_skill_md').default(false),
githubStars: integer('github_stars').default(0),
githubForks: integer('github_forks').default(0),
defaultBranch: text('default_branch').default('main'),
isArchived: boolean('is_archived').default(false),
scanError: text('scan_error'), // Last scan error if any
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
ownerIdx: index('idx_discovered_repos_owner').on(table.owner),
discoveredViaIdx: index('idx_discovered_repos_discovered_via').on(table.discoveredVia),
lastScannedIdx: index('idx_discovered_repos_last_scanned').on(table.lastScanned),
skillCountIdx: index('idx_discovered_repos_skill_count').on(table.skillCount),
hasSkillMdIdx: index('idx_discovered_repos_has_skill_md').on(table.hasSkillMd),
})
);
/**
* Awesome lists - tracks curated lists that we crawl for repo discovery
*/
export const awesomeLists = pgTable(
'awesome_lists',
{
id: text('id').primaryKey(), // owner/repo
owner: text('owner').notNull(),
repo: text('repo').notNull(),
name: text('name'),
lastParsed: timestamp('last_parsed'),
repoCount: integer('repo_count').default(0), // Number of repos found in this list
isActive: boolean('is_active').default(true), // Whether to continue crawling this list
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
lastParsedIdx: index('idx_awesome_lists_last_parsed').on(table.lastParsed),
})
);
/**
* Removal requests - allows repo owners to request their skills be removed
*/
export const removalRequests = pgTable(
'removal_requests',
{
id: text('id').primaryKey(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
skillId: text('skill_id').notNull(), // Can reference non-existent skill if already removed
reason: text('reason').notNull(),
status: text('status').notNull().default('pending'), // pending, approved, rejected
verifiedOwner: boolean('verified_owner').default(false), // GitHub API verification result
createdAt: timestamp('created_at').defaultNow().notNull(),
resolvedAt: timestamp('resolved_at'),
resolvedBy: text('resolved_by').references(() => users.id),
resolutionNote: text('resolution_note'),
},
(table) => ({
userIdx: index('idx_removal_requests_user').on(table.userId),
skillIdx: index('idx_removal_requests_skill').on(table.skillId),
statusIdx: index('idx_removal_requests_status').on(table.status),
})
);
/**
* Add requests - allows users to request new skills be indexed
*/
export const addRequests = pgTable(
'add_requests',
{
id: text('id').primaryKey(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
repositoryUrl: text('repository_url').notNull(), // Full GitHub URL
skillPath: text('skill_path'), // Optional path within repo (for subfolder skills)
reason: text('reason').notNull(), // Why should this skill be added
status: text('status').notNull().default('pending'), // pending, approved, rejected, indexed
validRepo: boolean('valid_repo').default(false), // GitHub API validation result
hasSkillMd: boolean('has_skill_md').default(false), // Whether SKILL.md was found
createdAt: timestamp('created_at').defaultNow().notNull(),
processedAt: timestamp('processed_at'),
indexedSkillId: text('indexed_skill_id'), // Reference to skill if successfully indexed
errorMessage: text('error_message'), // Error if indexing failed
},
(table) => ({
userIdx: index('idx_add_requests_user').on(table.userId),
statusIdx: index('idx_add_requests_status').on(table.status),
repoIdx: index('idx_add_requests_repo').on(table.repositoryUrl),
})
);
// Relations
export const skillsRelations = relations(skills, ({ many }) => ({
categories: many(skillCategories),
ratings: many(ratings),
installations: many(installations),
favorites: many(favorites),
}));
export const categoriesRelations = relations(categories, ({ many, one }) => ({
skills: many(skillCategories),
parent: one(categories, {
fields: [categories.parentId],
references: [categories.id],
}),
}));
export const skillCategoriesRelations = relations(skillCategories, ({ one }) => ({
skill: one(skills, {
fields: [skillCategories.skillId],
references: [skills.id],
}),
category: one(categories, {
fields: [skillCategories.categoryId],
references: [categories.id],
}),
}));
export const usersRelations = relations(users, ({ many }) => ({
ratings: many(ratings),
favorites: many(favorites),
removalRequests: many(removalRequests),
addRequests: many(addRequests),
}));
export const ratingsRelations = relations(ratings, ({ one }) => ({
skill: one(skills, {
fields: [ratings.skillId],
references: [skills.id],
}),
user: one(users, {
fields: [ratings.userId],
references: [users.id],
}),
}));
export const installationsRelations = relations(installations, ({ one }) => ({
skill: one(skills, {
fields: [installations.skillId],
references: [skills.id],
}),
}));
export const favoritesRelations = relations(favorites, ({ one }) => ({
user: one(users, {
fields: [favorites.userId],
references: [users.id],
}),
skill: one(skills, {
fields: [favorites.skillId],
references: [skills.id],
}),
}));
export const removalRequestsRelations = relations(removalRequests, ({ one }) => ({
user: one(users, {
fields: [removalRequests.userId],
references: [users.id],
}),
resolver: one(users, {
fields: [removalRequests.resolvedBy],
references: [users.id],
}),
}));
export const addRequestsRelations = relations(addRequests, ({ one }) => ({
user: one(users, {
fields: [addRequests.userId],
references: [users.id],
}),
}));
/**
* Email subscriptions for newsletter and marketing emails
*/
export const emailSubscriptions = pgTable(
'email_subscriptions',
{
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
source: text('source').notNull(), // 'oauth', 'newsletter', 'claim', 'early-access'
marketingConsent: boolean('marketing_consent').default(false),
consentDate: timestamp('consent_date'),
createdAt: timestamp('created_at').defaultNow().notNull(),
unsubscribedAt: timestamp('unsubscribed_at'),
},
(table) => ({
emailIdx: uniqueIndex('idx_email_subscriptions_email').on(table.email),
sourceIdx: index('idx_email_subscriptions_source').on(table.source),
})
);

View File

@@ -0,0 +1,214 @@
/**
* Test utilities for @skillhub/db package
*
* Factory functions and helpers for creating test data
*/
import type { skills, categories, users, ratings, favorites, installations } from './schema.js';
type SkillInsert = typeof skills.$inferInsert;
type CategoryInsert = typeof categories.$inferInsert;
type UserInsert = typeof users.$inferInsert;
type RatingInsert = typeof ratings.$inferInsert;
type FavoriteInsert = typeof favorites.$inferInsert;
type InstallationInsert = typeof installations.$inferInsert;
/**
* Create a test skill with sensible defaults
*/
export function createTestSkill(overrides: Partial<SkillInsert> = {}): SkillInsert {
const id = overrides.id || 'test-owner/test-repo/test-skill';
return {
id,
name: 'test-skill',
description: 'A test skill for unit testing',
githubOwner: 'test-owner',
githubRepo: 'test-repo',
skillPath: 'skills/test-skill',
branch: 'main',
version: '1.0.0',
license: 'MIT',
author: 'Test Author',
compatibility: {
platforms: ['claude', 'codex'],
},
githubStars: 100,
githubForks: 10,
downloadCount: 50,
viewCount: 200,
rating: 4,
ratingCount: 5,
ratingSum: 20,
securityScore: 85,
isVerified: false,
isFeatured: false,
rawContent: '# Test Skill\n\nThis is a test skill.',
contentHash: 'abc123',
createdAt: new Date(),
updatedAt: new Date(),
indexedAt: new Date(),
...overrides,
};
}
/**
* Create a test category with sensible defaults
*/
export function createTestCategory(overrides: Partial<CategoryInsert> = {}): CategoryInsert {
const id = overrides.id || `cat-${Date.now()}`;
return {
id,
name: 'Test Category',
slug: overrides.slug || `test-category-${Date.now()}`,
description: 'A test category',
icon: 'folder',
color: '#3B82F6',
sortOrder: 0,
skillCount: 0,
createdAt: new Date(),
...overrides,
};
}
/**
* Create a test user with sensible defaults
*/
export function createTestUser(overrides: Partial<UserInsert> = {}): UserInsert {
const id = overrides.id || `user-${Date.now()}`;
return {
id,
githubId: overrides.githubId || `gh-${Date.now()}`,
username: 'testuser',
displayName: 'Test User',
email: 'test@example.com',
avatarUrl: 'https://example.com/avatar.png',
bio: 'A test user',
isAdmin: false,
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: new Date(),
...overrides,
};
}
/**
* Create a test rating with sensible defaults
*/
export function createTestRating(overrides: Partial<RatingInsert> = {}): RatingInsert {
return {
id: overrides.id || `rating-${Date.now()}`,
skillId: 'test-owner/test-repo/test-skill',
userId: 'user-1',
rating: 4,
review: 'Great skill!',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
/**
* Create a test favorite with sensible defaults
*/
export function createTestFavorite(overrides: Partial<FavoriteInsert> = {}): FavoriteInsert {
return {
userId: 'user-1',
skillId: 'test-owner/test-repo/test-skill',
createdAt: new Date(),
...overrides,
};
}
/**
* Create a test installation with sensible defaults
*/
export function createTestInstallation(overrides: Partial<InstallationInsert> = {}): InstallationInsert {
return {
id: overrides.id || `install-${Date.now()}`,
skillId: 'test-owner/test-repo/test-skill',
platform: 'claude',
method: 'cli',
createdAt: new Date(),
...overrides,
};
}
/**
* Mock database type for testing
* This provides a mock implementation of the database client
*/
export interface MockDb {
select: ReturnType<typeof vi.fn>;
insert: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
}
/**
* Create a chainable mock for database operations
*/
export function createMockDbChain(finalResult: unknown = []) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
const createChainedMock = (): ReturnType<typeof vi.fn> => {
const mock = vi.fn().mockImplementation(() => {
return new Proxy({}, {
get: (_target, prop: string) => {
if (prop === 'then') {
return (resolve: (value: unknown) => void) => resolve(finalResult);
}
if (!chain[prop]) {
chain[prop] = createChainedMock();
}
return chain[prop];
},
});
});
return mock;
};
return {
select: createChainedMock(),
insert: createChainedMock(),
update: createChainedMock(),
delete: createChainedMock(),
};
}
/**
* Create a simple mock database for unit tests
*/
export function createMockDb() {
const mockResult: unknown[] = [];
const createChained = () => {
const chained = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockReturnThis(),
onConflictDoNothing: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue(mockResult),
then: (resolve: (v: unknown) => void) => Promise.resolve(mockResult).then(resolve),
};
return chained;
};
return {
select: vi.fn().mockReturnValue(createChained()),
insert: vi.fn().mockReturnValue(createChained()),
update: vi.fn().mockReturnValue(createChained()),
delete: vi.fn().mockReturnValue(createChained()),
_setMockResult: (result: unknown[]) => {
mockResult.length = 0;
mockResult.push(...result);
},
};
}