@girardmedia/bootspring 3.3.2 → 3.4.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/assets/agents/accessibility-auditor.md +39 -0
- package/assets/agents/api-designer.md +40 -0
- package/assets/agents/auth-implementer.md +64 -0
- package/assets/agents/bug-hunter.md +42 -0
- package/assets/agents/bundle-analyzer.md +40 -0
- package/assets/agents/cache-optimizer.md +55 -0
- package/assets/agents/changelog-writer.md +55 -0
- package/assets/agents/ci-cd-builder.md +40 -0
- package/assets/agents/code-explainer.md +39 -0
- package/assets/agents/code-reviewer.md +39 -0
- package/assets/agents/cost-optimizer.md +57 -0
- package/assets/agents/cron-scheduler.md +51 -0
- package/assets/agents/data-seeder.md +56 -0
- package/assets/agents/database-architect.md +40 -0
- package/assets/agents/dependency-updater.md +40 -0
- package/assets/agents/deploy-checker.md +40 -0
- package/assets/agents/docker-optimizer.md +40 -0
- package/assets/agents/documentation-writer.md +40 -0
- package/assets/agents/email-builder.md +55 -0
- package/assets/agents/env-setup.md +40 -0
- package/assets/agents/error-handler.md +40 -0
- package/assets/agents/eslint-fixer.md +46 -0
- package/assets/agents/feature-flagger.md +69 -0
- package/assets/agents/git-detective.md +39 -0
- package/assets/agents/graphql-builder.md +60 -0
- package/assets/agents/incident-responder.md +59 -0
- package/assets/agents/log-analyzer.md +39 -0
- package/assets/agents/migration-planner.md +41 -0
- package/assets/agents/monorepo-navigator.md +39 -0
- package/assets/agents/nextjs-expert.md +57 -0
- package/assets/agents/notification-builder.md +56 -0
- package/assets/agents/onboarding-guide.md +39 -0
- package/assets/agents/performance-profiler.md +40 -0
- package/assets/agents/prisma-expert.md +57 -0
- package/assets/agents/rate-limiter.md +58 -0
- package/assets/agents/react-expert.md +58 -0
- package/assets/agents/refactorer.md +42 -0
- package/assets/agents/regex-builder.md +46 -0
- package/assets/agents/release-manager.md +40 -0
- package/assets/agents/s3-manager.md +58 -0
- package/assets/agents/schema-validator.md +40 -0
- package/assets/agents/search-builder.md +62 -0
- package/assets/agents/security-auditor.md +39 -0
- package/assets/agents/sitemap-generator.md +53 -0
- package/assets/agents/stripe-integrator.md +59 -0
- package/assets/agents/tailwind-expert.md +55 -0
- package/assets/agents/tech-debt-tracker.md +39 -0
- package/assets/agents/test-writer.md +42 -0
- package/assets/agents/type-fixer.md +45 -0
- package/assets/agents/webhook-builder.md +54 -0
- package/assets/rules/cpp.md +53 -0
- package/assets/rules/css.md +52 -0
- package/assets/rules/go.md +50 -0
- package/assets/rules/html.md +52 -0
- package/assets/rules/java.md +51 -0
- package/assets/rules/kotlin.md +50 -0
- package/assets/rules/php.md +51 -0
- package/assets/rules/python.md +51 -0
- package/assets/rules/ruby.md +51 -0
- package/assets/rules/rust.md +49 -0
- package/assets/rules/shell.md +52 -0
- package/assets/rules/sql.md +49 -0
- package/assets/rules/swift.md +50 -0
- package/assets/rules/typescript.md +52 -0
- package/assets/rules/yaml-json.md +51 -0
- package/assets/skills/accessibility.md +210 -0
- package/assets/skills/agent-patterns.md +387 -0
- package/assets/skills/ai-integration.md +263 -0
- package/assets/skills/animation-patterns.md +224 -0
- package/assets/skills/api-design.md +218 -0
- package/assets/skills/api-gateway.md +341 -0
- package/assets/skills/api-versioning.md +226 -0
- package/assets/skills/astro-patterns.md +233 -0
- package/assets/skills/auth-patterns.md +248 -0
- package/assets/skills/aws-patterns.md +171 -0
- package/assets/skills/background-jobs.md +162 -0
- package/assets/skills/browser-extensions.md +309 -0
- package/assets/skills/caching-patterns.md +253 -0
- package/assets/skills/ci-cd.md +251 -0
- package/assets/skills/cli-development.md +296 -0
- package/assets/skills/code-review.md +185 -0
- package/assets/skills/cron-patterns.md +327 -0
- package/assets/skills/data-fetching.md +231 -0
- package/assets/skills/database-migrations.md +346 -0
- package/assets/skills/database-patterns.md +219 -0
- package/assets/skills/debugging.md +281 -0
- package/assets/skills/design-system.md +289 -0
- package/assets/skills/django-patterns.md +182 -0
- package/assets/skills/docker-patterns.md +235 -0
- package/assets/skills/e2e-testing.md +287 -0
- package/assets/skills/edge-computing.md +268 -0
- package/assets/skills/electron-patterns.md +266 -0
- package/assets/skills/email-templates.md +206 -0
- package/assets/skills/error-handling.md +265 -0
- package/assets/skills/event-driven.md +232 -0
- package/assets/skills/express-patterns.md +239 -0
- package/assets/skills/fastapi-patterns.md +198 -0
- package/assets/skills/feature-flags.md +212 -0
- package/assets/skills/figma-to-code.md +298 -0
- package/assets/skills/file-upload.md +228 -0
- package/assets/skills/forms-patterns.md +264 -0
- package/assets/skills/gcp-patterns.md +189 -0
- package/assets/skills/git-workflow.md +187 -0
- package/assets/skills/golang-patterns.md +185 -0
- package/assets/skills/graphql-patterns.md +244 -0
- package/assets/skills/i18n-patterns.md +172 -0
- package/assets/skills/image-processing.md +350 -0
- package/assets/skills/java-springboot.md +226 -0
- package/assets/skills/kotlin-patterns.md +207 -0
- package/assets/skills/kubernetes-patterns.md +326 -0
- package/assets/skills/laravel-patterns.md +261 -0
- package/assets/skills/llm-fine-tuning.md +335 -0
- package/assets/skills/load-testing.md +303 -0
- package/assets/skills/logging-observability.md +228 -0
- package/assets/skills/markdown-processing.md +318 -0
- package/assets/skills/mcp-server-patterns.md +292 -0
- package/assets/skills/microservices.md +272 -0
- package/assets/skills/migration-patterns.md +239 -0
- package/assets/skills/mongodb-patterns.md +189 -0
- package/assets/skills/monorepo-patterns.md +287 -0
- package/assets/skills/nextjs-app-router.md +237 -0
- package/assets/skills/notification-patterns.md +348 -0
- package/assets/skills/oauth-patterns.md +246 -0
- package/assets/skills/payment-integration.md +222 -0
- package/assets/skills/pdf-generation.md +307 -0
- package/assets/skills/performance-optimization.md +277 -0
- package/assets/skills/php-patterns.md +210 -0
- package/assets/skills/prisma-patterns.md +241 -0
- package/assets/skills/prompt-engineering.md +193 -0
- package/assets/skills/pwa-patterns.md +247 -0
- package/assets/skills/python-patterns.md +158 -0
- package/assets/skills/python-testing.md +172 -0
- package/assets/skills/queue-patterns.md +295 -0
- package/assets/skills/rag-patterns.md +159 -0
- package/assets/skills/rate-limiting.md +319 -0
- package/assets/skills/react-components.md +201 -0
- package/assets/skills/react-native-patterns.md +299 -0
- package/assets/skills/real-time-patterns.md +181 -0
- package/assets/skills/redis-patterns.md +188 -0
- package/assets/skills/refactoring.md +218 -0
- package/assets/skills/regex-patterns.md +191 -0
- package/assets/skills/remix-patterns.md +262 -0
- package/assets/skills/responsive-design.md +199 -0
- package/assets/skills/ruby-rails-patterns.md +178 -0
- package/assets/skills/rust-patterns.md +211 -0
- package/assets/skills/search-patterns.md +227 -0
- package/assets/skills/security-hardening.md +237 -0
- package/assets/skills/seo-patterns.md +179 -0
- package/assets/skills/serverless-patterns.md +223 -0
- package/assets/skills/sql-optimization.md +154 -0
- package/assets/skills/state-management.md +254 -0
- package/assets/skills/storybook-patterns.md +330 -0
- package/assets/skills/svelte-patterns.md +258 -0
- package/assets/skills/swift-patterns.md +227 -0
- package/assets/skills/tailwind-patterns.md +272 -0
- package/assets/skills/tdd-workflow.md +199 -0
- package/assets/skills/terraform-patterns.md +270 -0
- package/assets/skills/testing-react.md +240 -0
- package/assets/skills/testing-vitest.md +232 -0
- package/assets/skills/typescript-strict.md +159 -0
- package/assets/skills/video-processing.md +340 -0
- package/assets/skills/vue-patterns.md +247 -0
- package/assets/skills/web-workers.md +327 -0
- package/assets/skills/webhooks-patterns.md +283 -0
- package/assets/skills/websocket-patterns.md +306 -0
- package/dist/cli/index.js +941 -958
- package/dist/core/index.d.ts +341 -11
- package/dist/core.js +58 -95
- package/dist/mcp/index.d.ts +33 -1
- package/dist/mcp-server.js +177 -255
- package/package.json +4 -1
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cron-patterns
|
|
3
|
+
description: Cron job patterns for node-cron scheduling, idempotent tasks, distributed locks, health monitoring, and graceful shutdown.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cron Job Patterns
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
Use cron patterns for recurring tasks: database cleanup, report generation, cache warming, health checks, subscription billing, and data synchronization. These patterns ensure jobs run reliably, do not overlap, handle failures gracefully, and work correctly in multi-instance deployments. Apply distributed locks when running multiple server instances to prevent duplicate execution.
|
|
10
|
+
|
|
11
|
+
## How It Works
|
|
12
|
+
|
|
13
|
+
### node-cron Scheduling
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// src/cron/scheduler.ts
|
|
17
|
+
import cron from 'node-cron';
|
|
18
|
+
|
|
19
|
+
interface CronJob {
|
|
20
|
+
name: string;
|
|
21
|
+
schedule: string;
|
|
22
|
+
handler: () => Promise<void>;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const jobs: CronJob[] = [
|
|
27
|
+
{
|
|
28
|
+
name: 'cleanup-expired-sessions',
|
|
29
|
+
schedule: '0 */6 * * *', // every 6 hours
|
|
30
|
+
handler: cleanupExpiredSessions,
|
|
31
|
+
enabled: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'generate-daily-report',
|
|
35
|
+
schedule: '0 7 * * *', // daily at 7 AM
|
|
36
|
+
handler: generateDailyReport,
|
|
37
|
+
enabled: true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'sync-external-data',
|
|
41
|
+
schedule: '*/15 * * * *', // every 15 minutes
|
|
42
|
+
handler: syncExternalData,
|
|
43
|
+
enabled: true,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'billing-check',
|
|
47
|
+
schedule: '0 0 1 * *', // first of every month
|
|
48
|
+
handler: processBilling,
|
|
49
|
+
enabled: true,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const scheduledTasks: cron.ScheduledTask[] = [];
|
|
54
|
+
|
|
55
|
+
export function startScheduler() {
|
|
56
|
+
for (const job of jobs) {
|
|
57
|
+
if (!job.enabled) continue;
|
|
58
|
+
|
|
59
|
+
if (!cron.validate(job.schedule)) {
|
|
60
|
+
console.error(`Invalid cron schedule for ${job.name}: ${job.schedule}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const task = cron.schedule(job.schedule, async () => {
|
|
65
|
+
const start = Date.now();
|
|
66
|
+
console.log(`[cron] Starting: ${job.name}`);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
await job.handler();
|
|
70
|
+
console.log(`[cron] Completed: ${job.name} (${Date.now() - start}ms)`);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error(`[cron] Failed: ${job.name}`, err);
|
|
73
|
+
await reportCronFailure(job.name, err as Error);
|
|
74
|
+
}
|
|
75
|
+
}, {
|
|
76
|
+
timezone: 'UTC',
|
|
77
|
+
runOnInit: false,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
scheduledTasks.push(task);
|
|
81
|
+
console.log(`[cron] Registered: ${job.name} [${job.schedule}]`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function stopScheduler() {
|
|
86
|
+
scheduledTasks.forEach((task) => task.stop());
|
|
87
|
+
console.log(`[cron] Stopped ${scheduledTasks.length} tasks`);
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Idempotent Job Execution
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// src/cron/idempotent.ts
|
|
95
|
+
import { db } from '../lib/db';
|
|
96
|
+
|
|
97
|
+
interface JobRun {
|
|
98
|
+
jobName: string;
|
|
99
|
+
runId: string;
|
|
100
|
+
startedAt: Date;
|
|
101
|
+
completedAt?: Date;
|
|
102
|
+
status: 'running' | 'completed' | 'failed';
|
|
103
|
+
result?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function runIdempotent(jobName: string, handler: () => Promise<string>): Promise<void> {
|
|
107
|
+
const runId = `${jobName}-${new Date().toISOString().split('T')[0]}`;
|
|
108
|
+
|
|
109
|
+
// Check if already run today
|
|
110
|
+
const existing = await db.query(
|
|
111
|
+
'SELECT status FROM cron_runs WHERE run_id = $1',
|
|
112
|
+
[runId]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (existing.rows.length > 0 && existing.rows[0].status === 'completed') {
|
|
116
|
+
console.log(`[cron] ${jobName} already completed for this period, skipping`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Record start
|
|
121
|
+
await db.query(
|
|
122
|
+
`INSERT INTO cron_runs (job_name, run_id, started_at, status)
|
|
123
|
+
VALUES ($1, $2, NOW(), 'running')
|
|
124
|
+
ON CONFLICT (run_id) DO UPDATE SET started_at = NOW(), status = 'running'`,
|
|
125
|
+
[jobName, runId]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const result = await handler();
|
|
130
|
+
await db.query(
|
|
131
|
+
`UPDATE cron_runs SET completed_at = NOW(), status = 'completed', result = $1
|
|
132
|
+
WHERE run_id = $2`,
|
|
133
|
+
[result, runId]
|
|
134
|
+
);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
await db.query(
|
|
137
|
+
`UPDATE cron_runs SET completed_at = NOW(), status = 'failed', result = $1
|
|
138
|
+
WHERE run_id = $2`,
|
|
139
|
+
[(err as Error).message, runId]
|
|
140
|
+
);
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Usage
|
|
146
|
+
async function generateDailyReport() {
|
|
147
|
+
await runIdempotent('daily-report', async () => {
|
|
148
|
+
const data = await aggregateMetrics();
|
|
149
|
+
await saveReport(data);
|
|
150
|
+
return `Generated report with ${data.totalRecords} records`;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Distributed Lock (Redis)
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
// src/cron/distributed-lock.ts
|
|
159
|
+
import Redis from 'ioredis';
|
|
160
|
+
|
|
161
|
+
const redis = new Redis(process.env.REDIS_URL!);
|
|
162
|
+
|
|
163
|
+
export async function withDistributedLock<T>(
|
|
164
|
+
lockName: string,
|
|
165
|
+
ttlMs: number,
|
|
166
|
+
fn: () => Promise<T>
|
|
167
|
+
): Promise<T | null> {
|
|
168
|
+
const lockKey = `lock:cron:${lockName}`;
|
|
169
|
+
const lockValue = `${process.pid}:${Date.now()}`;
|
|
170
|
+
|
|
171
|
+
// Acquire lock (NX = only if not exists, PX = TTL in ms)
|
|
172
|
+
const acquired = await redis.set(lockKey, lockValue, 'PX', ttlMs, 'NX');
|
|
173
|
+
|
|
174
|
+
if (!acquired) {
|
|
175
|
+
console.log(`[cron] Lock "${lockName}" held by another instance, skipping`);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = await fn();
|
|
181
|
+
return result;
|
|
182
|
+
} finally {
|
|
183
|
+
// Release lock only if we still own it (Lua script for atomicity)
|
|
184
|
+
await redis.eval(
|
|
185
|
+
`if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
186
|
+
return redis.call("del", KEYS[1])
|
|
187
|
+
else
|
|
188
|
+
return 0
|
|
189
|
+
end`,
|
|
190
|
+
1, lockKey, lockValue
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Usage: only one instance runs cleanup at a time
|
|
196
|
+
async function cleanupExpiredSessions() {
|
|
197
|
+
await withDistributedLock('cleanup-sessions', 300_000, async () => {
|
|
198
|
+
const deleted = await db.query(
|
|
199
|
+
'DELETE FROM sessions WHERE expires_at < NOW() RETURNING id'
|
|
200
|
+
);
|
|
201
|
+
console.log(`Cleaned up ${deleted.rowCount} expired sessions`);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Overlap Prevention
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
// src/cron/no-overlap.ts
|
|
210
|
+
const runningJobs = new Set<string>();
|
|
211
|
+
|
|
212
|
+
function withNoOverlap(jobName: string, handler: () => Promise<void>) {
|
|
213
|
+
return async () => {
|
|
214
|
+
if (runningJobs.has(jobName)) {
|
|
215
|
+
console.log(`[cron] ${jobName} still running, skipping this invocation`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
runningJobs.add(jobName);
|
|
220
|
+
try {
|
|
221
|
+
await handler();
|
|
222
|
+
} finally {
|
|
223
|
+
runningJobs.delete(jobName);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Register with overlap guard
|
|
229
|
+
cron.schedule('*/5 * * * *', withNoOverlap('sync-data', async () => {
|
|
230
|
+
// This might take 3-10 minutes
|
|
231
|
+
await syncAllRecords();
|
|
232
|
+
}));
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Health Monitoring
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
// src/cron/health.ts
|
|
239
|
+
interface CronHealth {
|
|
240
|
+
name: string;
|
|
241
|
+
lastRun: string | null;
|
|
242
|
+
lastStatus: 'completed' | 'failed' | null;
|
|
243
|
+
nextRun: string;
|
|
244
|
+
overdue: boolean;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function getCronHealth(): Promise<CronHealth[]> {
|
|
248
|
+
const results: CronHealth[] = [];
|
|
249
|
+
|
|
250
|
+
for (const job of jobs) {
|
|
251
|
+
const lastRun = await db.query(
|
|
252
|
+
'SELECT started_at, status FROM cron_runs WHERE job_name = $1 ORDER BY started_at DESC LIMIT 1',
|
|
253
|
+
[job.name]
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const expression = cron.validate(job.schedule) ? job.schedule : null;
|
|
257
|
+
const interval = expression ? cron.getTasks().get(job.name) : null;
|
|
258
|
+
|
|
259
|
+
results.push({
|
|
260
|
+
name: job.name,
|
|
261
|
+
lastRun: lastRun.rows[0]?.started_at?.toISOString() ?? null,
|
|
262
|
+
lastStatus: lastRun.rows[0]?.status ?? null,
|
|
263
|
+
nextRun: getNextCronDate(job.schedule).toISOString(),
|
|
264
|
+
overdue: isOverdue(job.schedule, lastRun.rows[0]?.started_at),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return results;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function isOverdue(schedule: string, lastRun?: Date): boolean {
|
|
272
|
+
if (!lastRun) return true;
|
|
273
|
+
const expected = getNextCronDate(schedule, lastRun);
|
|
274
|
+
return new Date() > new Date(expected.getTime() + 60_000); // 1min grace
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// API endpoint
|
|
278
|
+
app.get('/admin/cron/health', async (_req, res) => {
|
|
279
|
+
const health = await getCronHealth();
|
|
280
|
+
const allHealthy = health.every((h) => !h.overdue && h.lastStatus !== 'failed');
|
|
281
|
+
res.status(allHealthy ? 200 : 503).json({ jobs: health });
|
|
282
|
+
});
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Graceful Shutdown
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
// src/index.ts
|
|
289
|
+
import { startScheduler, stopScheduler } from './cron/scheduler';
|
|
290
|
+
|
|
291
|
+
startScheduler();
|
|
292
|
+
|
|
293
|
+
process.on('SIGTERM', async () => {
|
|
294
|
+
console.log('SIGTERM received, shutting down gracefully...');
|
|
295
|
+
stopScheduler();
|
|
296
|
+
// Wait for running jobs to finish (max 30s)
|
|
297
|
+
const deadline = Date.now() + 30_000;
|
|
298
|
+
while (runningJobs.size > 0 && Date.now() < deadline) {
|
|
299
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
300
|
+
}
|
|
301
|
+
if (runningJobs.size > 0) {
|
|
302
|
+
console.warn(`Forcing shutdown with ${runningJobs.size} jobs still running`);
|
|
303
|
+
}
|
|
304
|
+
process.exit(0);
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Examples
|
|
309
|
+
|
|
310
|
+
| Schedule | Cron Expression | Description |
|
|
311
|
+
|----------|----------------|-------------|
|
|
312
|
+
| Every minute | `* * * * *` | Heartbeat, queue polling |
|
|
313
|
+
| Every 15 min | `*/15 * * * *` | Data sync, cache refresh |
|
|
314
|
+
| Hourly | `0 * * * *` | Metrics aggregation |
|
|
315
|
+
| Daily 7 AM | `0 7 * * *` | Reports, digests |
|
|
316
|
+
| Weekly Monday | `0 0 * * 1` | Cleanup, archival |
|
|
317
|
+
| Monthly 1st | `0 0 1 * *` | Billing, invoicing |
|
|
318
|
+
|
|
319
|
+
## Checklist
|
|
320
|
+
- [ ] All cron expressions validated at startup with `cron.validate()`
|
|
321
|
+
- [ ] Jobs are idempotent (safe to re-run without side effects)
|
|
322
|
+
- [ ] Distributed lock prevents duplicate execution across instances
|
|
323
|
+
- [ ] Overlap guard prevents concurrent runs of the same job
|
|
324
|
+
- [ ] Job runs recorded in database with status, timing, and result
|
|
325
|
+
- [ ] Health endpoint reports last run, status, and overdue state
|
|
326
|
+
- [ ] Graceful shutdown waits for running jobs before exiting
|
|
327
|
+
- [ ] Failed jobs trigger alerts to on-call
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: data-fetching
|
|
3
|
+
description: Data fetching patterns with SWR/TanStack Query, suspense, pagination, infinite scroll, and prefetching.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Data Fetching
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
Apply when building React applications that consume API data. Use TanStack Query (React Query) or SWR for caching, deduplication, and background refetching. These patterns eliminate boilerplate, prevent waterfalls, and keep the UI in sync with server state.
|
|
10
|
+
|
|
11
|
+
## How It Works
|
|
12
|
+
|
|
13
|
+
### TanStack Query -- The Standard
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
17
|
+
|
|
18
|
+
// Fetch with automatic caching, refetching, and error handling
|
|
19
|
+
function UserProfile({ userId }: { userId: string }) {
|
|
20
|
+
const { data: user, isLoading, error } = useQuery({
|
|
21
|
+
queryKey: ["user", userId],
|
|
22
|
+
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
|
|
23
|
+
staleTime: 5 * 60 * 1000, // consider fresh for 5 minutes
|
|
24
|
+
gcTime: 30 * 60 * 1000, // keep in cache for 30 minutes
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (isLoading) return <Skeleton />;
|
|
28
|
+
if (error) return <ErrorMessage error={error} />;
|
|
29
|
+
return <div>{user.name}</div>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Mutation with optimistic update and cache invalidation
|
|
33
|
+
function useUpdateUser() {
|
|
34
|
+
const queryClient = useQueryClient();
|
|
35
|
+
|
|
36
|
+
return useMutation({
|
|
37
|
+
mutationFn: (data: { id: string; name: string }) =>
|
|
38
|
+
fetch(`/api/users/${data.id}`, {
|
|
39
|
+
method: "PATCH",
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
body: JSON.stringify(data),
|
|
42
|
+
}).then((r) => r.json()),
|
|
43
|
+
|
|
44
|
+
onMutate: async (newData) => {
|
|
45
|
+
await queryClient.cancelQueries({ queryKey: ["user", newData.id] });
|
|
46
|
+
const previous = queryClient.getQueryData(["user", newData.id]);
|
|
47
|
+
queryClient.setQueryData(["user", newData.id], (old: any) => ({
|
|
48
|
+
...old,
|
|
49
|
+
...newData,
|
|
50
|
+
}));
|
|
51
|
+
return { previous };
|
|
52
|
+
},
|
|
53
|
+
onError: (_err, newData, context) => {
|
|
54
|
+
queryClient.setQueryData(["user", newData.id], context?.previous);
|
|
55
|
+
},
|
|
56
|
+
onSettled: (_data, _err, variables) => {
|
|
57
|
+
queryClient.invalidateQueries({ queryKey: ["user", variables.id] });
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### SWR -- Lightweight Alternative
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import useSWR from "swr";
|
|
67
|
+
|
|
68
|
+
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
69
|
+
|
|
70
|
+
function Dashboard() {
|
|
71
|
+
const { data, error, isLoading, mutate } = useSWR("/api/dashboard", fetcher, {
|
|
72
|
+
refreshInterval: 30_000, // poll every 30s
|
|
73
|
+
revalidateOnFocus: true, // refetch when tab regains focus
|
|
74
|
+
dedupingInterval: 2000, // dedupe requests within 2s
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Optimistic update
|
|
78
|
+
const handleRefresh = () => {
|
|
79
|
+
mutate(
|
|
80
|
+
fetch("/api/dashboard").then((r) => r.json()),
|
|
81
|
+
{ optimisticData: { ...data, lastRefresh: new Date() } }
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (isLoading) return <Skeleton />;
|
|
86
|
+
if (error) return <ErrorMessage error={error} />;
|
|
87
|
+
return <DashboardView data={data} onRefresh={handleRefresh} />;
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Cursor-Based Pagination
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
function useOrders() {
|
|
95
|
+
return useInfiniteQuery({
|
|
96
|
+
queryKey: ["orders"],
|
|
97
|
+
queryFn: ({ pageParam }) =>
|
|
98
|
+
fetch(`/api/orders?cursor=${pageParam ?? ""}&limit=20`).then((r) => r.json()),
|
|
99
|
+
initialPageParam: undefined as string | undefined,
|
|
100
|
+
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function OrderList() {
|
|
105
|
+
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useOrders();
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div>
|
|
109
|
+
{data?.pages.flatMap((page) =>
|
|
110
|
+
page.orders.map((order: Order) => (
|
|
111
|
+
<OrderCard key={order.id} order={order} />
|
|
112
|
+
))
|
|
113
|
+
)}
|
|
114
|
+
{hasNextPage && (
|
|
115
|
+
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
|
|
116
|
+
{isFetchingNextPage ? "Loading..." : "Load More"}
|
|
117
|
+
</button>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Infinite Scroll with Intersection Observer
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
function InfiniteOrderList() {
|
|
128
|
+
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useOrders();
|
|
129
|
+
const loadMoreRef = useRef<HTMLDivElement>(null);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const observer = new IntersectionObserver(
|
|
133
|
+
(entries) => {
|
|
134
|
+
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
|
135
|
+
fetchNextPage();
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
{ rootMargin: "200px" }
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
|
|
142
|
+
return () => observer.disconnect();
|
|
143
|
+
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div>
|
|
147
|
+
{data?.pages.flatMap((page) =>
|
|
148
|
+
page.orders.map((order: Order) => (
|
|
149
|
+
<OrderCard key={order.id} order={order} />
|
|
150
|
+
))
|
|
151
|
+
)}
|
|
152
|
+
<div ref={loadMoreRef} />
|
|
153
|
+
{isFetchingNextPage && <Spinner />}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Prefetching for Instant Navigation
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
function OrderRow({ order }: { order: Order }) {
|
|
163
|
+
const queryClient = useQueryClient();
|
|
164
|
+
|
|
165
|
+
// Prefetch on hover so detail page loads instantly
|
|
166
|
+
const handleMouseEnter = () => {
|
|
167
|
+
queryClient.prefetchQuery({
|
|
168
|
+
queryKey: ["order", order.id],
|
|
169
|
+
queryFn: () => fetch(`/api/orders/${order.id}`).then((r) => r.json()),
|
|
170
|
+
staleTime: 60_000,
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Link
|
|
176
|
+
href={`/orders/${order.id}`}
|
|
177
|
+
onMouseEnter={handleMouseEnter}
|
|
178
|
+
>
|
|
179
|
+
{order.id} -- ${order.total}
|
|
180
|
+
</Link>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### React Suspense Integration
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
import { useSuspenseQuery } from "@tanstack/react-query";
|
|
189
|
+
import { Suspense } from "react";
|
|
190
|
+
|
|
191
|
+
function UserProfileSuspense({ userId }: { userId: string }) {
|
|
192
|
+
const { data: user } = useSuspenseQuery({
|
|
193
|
+
queryKey: ["user", userId],
|
|
194
|
+
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return <div>{user.name}</div>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Parent handles loading and error states
|
|
201
|
+
function UserPage({ userId }: { userId: string }) {
|
|
202
|
+
return (
|
|
203
|
+
<ErrorBoundary fallback={<ErrorMessage />}>
|
|
204
|
+
<Suspense fallback={<Skeleton />}>
|
|
205
|
+
<UserProfileSuspense userId={userId} />
|
|
206
|
+
</Suspense>
|
|
207
|
+
</ErrorBoundary>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Examples
|
|
213
|
+
|
|
214
|
+
| Pattern | When | Result |
|
|
215
|
+
|---------|------|--------|
|
|
216
|
+
| TanStack Query | Most React apps | Caching, dedup, background refetch |
|
|
217
|
+
| SWR | Simpler needs, smaller bundle | Stale-while-revalidate out of the box |
|
|
218
|
+
| Cursor pagination | Large lists | Consistent results, no offset drift |
|
|
219
|
+
| Infinite scroll | Social feeds, logs | Seamless loading as user scrolls |
|
|
220
|
+
| Prefetch on hover | Detail pages | Instant navigation, zero loading |
|
|
221
|
+
| Suspense | Streaming SSR | Declarative loading states |
|
|
222
|
+
|
|
223
|
+
## Checklist
|
|
224
|
+
- [ ] All API data fetched through TanStack Query or SWR, not raw useEffect
|
|
225
|
+
- [ ] `staleTime` and `gcTime` configured per query based on data freshness needs
|
|
226
|
+
- [ ] Mutations invalidate related queries for cache consistency
|
|
227
|
+
- [ ] Pagination uses cursor-based approach for large datasets
|
|
228
|
+
- [ ] Infinite scroll uses Intersection Observer with rootMargin
|
|
229
|
+
- [ ] Detail pages prefetched on hover for instant navigation
|
|
230
|
+
- [ ] Error boundaries wrap Suspense-based data fetching
|
|
231
|
+
- [ ] Loading skeletons match the shape of the loaded content
|