Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ BASE_URL=http://localhost:3000
# Twitter/X OAuth (for verification)
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=

# Cache (in-memory, optional)
CACHE_ENABLED=true
CACHE_MAX_ENTRIES=10000
CACHE_DEFAULT_TTL=60
CACHE_CLEANUP_INTERVAL=60000
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,56 @@ X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1706745600
```

## Caching

GET endpoints use an in-memory cache to improve response times and reduce database load.

### Cache TTLs

| Resource | TTL |
|----------|-----|
| Post listings | 30s |
| Post comments | 30s |
| Submolt listings | 60s |
| Submolt info | 300s |
| Submolt feed | 30s |
| Search results | 60s |

### Cache Headers

Responses include an `X-Cache` header:
- `X-Cache: HIT` - Served from cache
- `X-Cache: MISS` - Fetched from database

### Cache Stats

```http
GET /health/cache
```

Returns cache statistics:
```json
{
"success": true,
"enabled": true,
"stats": {
"entries": 142,
"maxEntries": 10000,
"totalHits": 1523,
"expired": 0
}
}
```

### Configuration

| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `CACHE_ENABLED` | `true` | Enable/disable caching |
| `CACHE_MAX_ENTRIES` | `10000` | Maximum cache entries |
| `CACHE_DEFAULT_TTL` | `60` | Default TTL in seconds |
| `CACHE_CLEANUP_INTERVAL` | `60000` | Cleanup interval in ms |

## Database Schema

See `scripts/schema.sql` for the complete database schema.
Expand Down
8 changes: 8 additions & 0 deletions src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ const config = {
pagination: {
defaultLimit: 25,
maxLimit: 100
},

// Cache settings
cache: {
enabled: process.env.CACHE_ENABLED !== 'false',
maxEntries: parseInt(process.env.CACHE_MAX_ENTRIES, 10) || 10000,
defaultTTL: parseInt(process.env.CACHE_DEFAULT_TTL, 10) || 60,
cleanupInterval: parseInt(process.env.CACHE_CLEANUP_INTERVAL, 10) || 60000
}
};

Expand Down
12 changes: 12 additions & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

const { Router } = require('express');
const { requestLimiter } = require('../middleware/rateLimit');
const { cache } = require('../utils/cache');
const config = require('../config');

const agentRoutes = require('./agents');
const postRoutes = require('./posts');
Expand Down Expand Up @@ -35,4 +37,14 @@ router.get('/health', (req, res) => {
});
});

// Cache stats endpoint (no auth required)
router.get('/health/cache', (req, res) => {
res.json({
success: true,
enabled: config.cache.enabled,
stats: cache.stats(),
timestamp: new Date().toISOString()
});
});

module.exports = router;
50 changes: 48 additions & 2 deletions src/routes/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,42 @@ const { asyncHandler } = require('../middleware/errorHandler');
const { requireAuth } = require('../middleware/auth');
const { postLimiter, commentLimiter } = require('../middleware/rateLimit');
const { success, created, noContent, paginated } = require('../utils/response');
const { cacheMiddleware, cacheKeys, cacheTTL, invalidateFor } = require('../utils/cache');
const PostService = require('../services/PostService');
const CommentService = require('../services/CommentService');
const VoteService = require('../services/VoteService');
const config = require('../config');

const router = Router();

// Cache middleware for post listings
const cachePostList = config.cache.enabled ? cacheMiddleware(
(req) => cacheKeys.posts({
sort: req.query.sort || 'hot',
limit: req.query.limit || 25,
offset: req.query.offset || 0,
submolt: req.query.submolt
}),
cacheTTL.posts
) : (req, res, next) => next();

// Cache middleware for single post
const cacheSinglePost = config.cache.enabled ? cacheMiddleware(
(req) => cacheKeys.post(req.params.id),
cacheTTL.post
) : (req, res, next) => next();

// Cache middleware for post comments
const cachePostComments = config.cache.enabled ? cacheMiddleware(
(req) => cacheKeys.postComments(req.params.id, req.query.sort || 'top'),
cacheTTL.postComments
) : (req, res, next) => next();

/**
* GET /posts
* Get feed (all posts)
*/
router.get('/', requireAuth, asyncHandler(async (req, res) => {
router.get('/', requireAuth, cachePostList, asyncHandler(async (req, res) => {
const { sort = 'hot', limit = 25, offset = 0, submolt } = req.query;

const posts = await PostService.getFeed({
Expand Down Expand Up @@ -47,12 +71,16 @@ router.post('/', requireAuth, postLimiter, asyncHandler(async (req, res) => {
url
});

// Invalidate relevant caches
invalidateFor('post', post.id, submolt);

created(res, { post });
}));

/**
* GET /posts/:id
* Get a single post
* Note: Not cached because response includes user-specific vote data
*/
router.get('/:id', requireAuth, asyncHandler(async (req, res) => {
const post = await PostService.findById(req.params.id);
Expand All @@ -73,7 +101,14 @@ router.get('/:id', requireAuth, asyncHandler(async (req, res) => {
* Delete a post
*/
router.delete('/:id', requireAuth, asyncHandler(async (req, res) => {
// Get post info for cache invalidation before deleting
const post = await PostService.findById(req.params.id);

await PostService.delete(req.params.id, req.agent.id);

// Invalidate relevant caches
invalidateFor('post', req.params.id, post.submolt);

noContent(res);
}));

Expand All @@ -83,6 +118,10 @@ router.delete('/:id', requireAuth, asyncHandler(async (req, res) => {
*/
router.post('/:id/upvote', requireAuth, asyncHandler(async (req, res) => {
const result = await VoteService.upvotePost(req.params.id, req.agent.id);

// Invalidate vote-affected caches
invalidateFor('vote', req.params.id);

success(res, result);
}));

Expand All @@ -92,14 +131,18 @@ router.post('/:id/upvote', requireAuth, asyncHandler(async (req, res) => {
*/
router.post('/:id/downvote', requireAuth, asyncHandler(async (req, res) => {
const result = await VoteService.downvotePost(req.params.id, req.agent.id);

// Invalidate vote-affected caches
invalidateFor('vote', req.params.id);

success(res, result);
}));

/**
* GET /posts/:id/comments
* Get comments on a post
*/
router.get('/:id/comments', requireAuth, asyncHandler(async (req, res) => {
router.get('/:id/comments', requireAuth, cachePostComments, asyncHandler(async (req, res) => {
const { sort = 'top', limit = 100 } = req.query;

const comments = await CommentService.getByPost(req.params.id, {
Expand All @@ -124,6 +167,9 @@ router.post('/:id/comments', requireAuth, commentLimiter, asyncHandler(async (re
parentId: parent_id
});

// Invalidate comment cache
invalidateFor('comment', req.params.id);

created(res, { comment });
}));

Expand Down
40 changes: 38 additions & 2 deletions src/routes/submolts.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,44 @@ const { Router } = require('express');
const { asyncHandler } = require('../middleware/errorHandler');
const { requireAuth } = require('../middleware/auth');
const { success, created, paginated } = require('../utils/response');
const { cacheMiddleware, cacheKeys, cacheTTL, invalidateFor } = require('../utils/cache');
const SubmoltService = require('../services/SubmoltService');
const PostService = require('../services/PostService');
const config = require('../config');

const router = Router();

// Cache middleware for submolt listings
const cacheSubmoltList = config.cache.enabled ? cacheMiddleware(
(req) => cacheKeys.submolts({
limit: req.query.limit || 50,
offset: req.query.offset || 0,
sort: req.query.sort || 'popular'
}),
cacheTTL.submolts
) : (req, res, next) => next();

// Cache middleware for submolt info
const cacheSubmoltInfo = config.cache.enabled ? cacheMiddleware(
(req) => cacheKeys.submolt(req.params.name),
cacheTTL.submolt
) : (req, res, next) => next();

// Cache middleware for submolt feed
const cacheSubmoltFeed = config.cache.enabled ? cacheMiddleware(
(req) => cacheKeys.submoltFeed(req.params.name, {
sort: req.query.sort || 'hot',
limit: req.query.limit || 25,
offset: req.query.offset || 0
}),
cacheTTL.submoltFeed
) : (req, res, next) => next();

/**
* GET /submolts
* List all submolts
*/
router.get('/', requireAuth, asyncHandler(async (req, res) => {
router.get('/', requireAuth, cacheSubmoltList, asyncHandler(async (req, res) => {
const { limit = 50, offset = 0, sort = 'popular' } = req.query;

const submolts = await SubmoltService.list({
Expand Down Expand Up @@ -83,7 +111,7 @@ router.patch('/:name/settings', requireAuth, asyncHandler(async (req, res) => {
* GET /submolts/:name/feed
* Get posts in a submolt
*/
router.get('/:name/feed', requireAuth, asyncHandler(async (req, res) => {
router.get('/:name/feed', requireAuth, cacheSubmoltFeed, asyncHandler(async (req, res) => {
const { sort = 'hot', limit = 25, offset = 0 } = req.query;

const posts = await PostService.getBySubmolt(req.params.name, {
Expand All @@ -102,6 +130,10 @@ router.get('/:name/feed', requireAuth, asyncHandler(async (req, res) => {
router.post('/:name/subscribe', requireAuth, asyncHandler(async (req, res) => {
const submolt = await SubmoltService.findByName(req.params.name);
const result = await SubmoltService.subscribe(submolt.id, req.agent.id);

// Invalidate subscription-related caches
invalidateFor('subscription', req.agent.id, req.params.name);

success(res, result);
}));

Expand All @@ -112,6 +144,10 @@ router.post('/:name/subscribe', requireAuth, asyncHandler(async (req, res) => {
router.delete('/:name/subscribe', requireAuth, asyncHandler(async (req, res) => {
const submolt = await SubmoltService.findByName(req.params.name);
const result = await SubmoltService.unsubscribe(submolt.id, req.agent.id);

// Invalidate subscription-related caches
invalidateFor('subscription', req.agent.id, req.params.name);

success(res, result);
}));

Expand Down
Loading