docs: add D1 and Cron guides, update module contract across docs

- docs/using-d1.md and docs/using-cron.md for module authors
- architecture, codebase-summary, adding-a-module, code-standards, deployment-guide refreshed
- CLAUDE.md module contract shows optional crons[] and sql in init
- docs/todo.md tracks manual follow-ups (D1 UUID, first deploy, smoke tests)
This commit is contained in:
2026-04-15 13:29:31 +07:00
parent 97ee30590a
commit f5e03cfff2
10 changed files with 985 additions and 81 deletions

283
docs/using-cron.md Normal file
View File

@@ -0,0 +1,283 @@
# Using Cron (Scheduled Jobs)
Cron allows modules to run scheduled tasks at fixed intervals. Use crons for cleanup (purge old data), maintenance (recompute stats), or periodic notifications.
## Declaring Crons
In your module's default export, add a `crons` array:
```js
export default {
name: "mymod",
init: async ({ db, sql, env }) => { /* ... */ },
commands: [ /* ... */ ],
crons: [
{
schedule: "0 17 * * *", // 5 PM UTC daily
name: "cleanup", // human-readable identifier
handler: async (event, ctx) => {
// event.cron = "0 17 * * *"
// event.scheduledTime = timestamp (ms)
// ctx = { db, sql, env } (same as module init)
},
},
],
};
```
**Handler signature:**
```js
async (event, { db, sql, env }) => {
// event.cron — the schedule string that fired
// event.scheduledTime — Unix timestamp (ms)
// db — namespaced KV store (same as init)
// sql — namespaced D1 store (same as init), null if not bound
// env — raw worker environment
}
```
## Cron Expression Syntax
Standard 5-field cron format (minute, hour, day-of-month, month, day-of-week):
```
minute hour day-of-month month day-of-week
0-59 0-23 1-31 1-12 0-6 (0=Sunday)
"0 17 * * *" — 5 PM UTC daily
"*/5 * * * *" — every 5 minutes
"0 0 1 * *" — midnight on the 1st of each month
"0 9 * * 1" — 9 AM UTC every Monday
"30 2 * * *" — 2:30 AM UTC daily
```
See Cloudflare's [cron expression docs](https://developers.cloudflare.com/workers/configuration/cron-triggers/) for full syntax.
## Registering in wrangler.toml
**Important:** Crons declared in the module MUST also be listed in `wrangler.toml`. This is because Cloudflare needs to know what schedules to fire at deploy time.
Edit `wrangler.toml`:
```toml
[triggers]
crons = ["0 17 * * *", "0 0 * * *", "*/5 * * * *"]
```
Both the module contract and the `[triggers] crons` array must list the same schedules. If a schedule is in the module but not in `wrangler.toml`, Cloudflare won't fire it. If it's in `wrangler.toml` but not in any module, the worker won't know what to do with it.
**Multiple modules can share a schedule** — all matching handlers will fire (fan-out). Each module must declare its own `crons` entry; the registry validates them at load time.
## Handler Details
### Error Isolation
If one handler fails, other handlers still run. Each handler is wrapped in try/catch:
```js
// In cron-dispatcher.js
for (const entry of matching) {
ctx.waitUntil(
(async () => {
try {
await entry.handler(event, handlerCtx);
} catch (err) {
console.error(`[cron] handler failed:`, err);
}
})(),
);
}
```
Errors are logged to the Workers console but don't crash the dispatch loop.
### Execution Time Limits
Cloudflare cron tasks have a **15-minute wall-clock limit**. Operations exceeding this timeout are killed. For large data operations:
- Batch in chunks (e.g., delete 1000 rows at a time, looping)
- Use pagination to avoid loading entire datasets into memory
- Monitor execution time and add logging
### Context Availability
Cron handlers run in the same Worker runtime as HTTP handlers, so they have access to:
- `db` — the module's namespaced KV store (read/write)
- `sql` — the module's namespaced D1 store (read/write), or null if not configured
- `env` — all Worker environment bindings (secrets, etc.)
### Return Value
Handlers should return `Promise<void>`. The runtime ignores return values.
## Local Testing
Use the local `wrangler dev` server to simulate cron triggers:
```bash
npm run dev
```
In another terminal, send a simulated cron request:
```bash
# Trigger the 5 PM daily cron
curl "http://localhost:8787/__scheduled?cron=0+17+*+*+*"
# URL-encode the cron string (spaces → +)
```
The Worker responds with `200` and logs handler output to the dev server console.
### Simulating Multiple Crons
If you have several crons with different schedules, test each by passing the exact schedule string:
```bash
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" # every 5 min
curl "http://localhost:8787/__scheduled?cron=0+0+1+*+*" # monthly
```
## Worked Example: Trade Retention
The trading module uses a daily cron at `0 17 * * *` (5 PM UTC) to trim old trades:
**Module declaration (src/modules/trading/index.js):**
```js
crons: [
{
schedule: "0 17 * * *",
name: "trim-trades",
handler: (event, ctx) => trimTradesHandler(event, ctx),
},
],
```
**wrangler.toml:**
```toml
[triggers]
crons = ["0 17 * * *"]
```
**Handler (src/modules/trading/retention.js):**
```js
/**
* Delete trades older than 90 days.
*/
export async function trimTradesHandler(event, { sql }) {
if (!sql) return; // database not configured
const ninetyDaysAgoMs = Date.now() - 90 * 24 * 60 * 60 * 1000;
const result = await sql.run(
"DELETE FROM trading_trades WHERE ts < ?",
ninetyDaysAgoMs
);
console.log(`[cron] trim-trades: deleted ${result.changes} old trades`);
}
```
**wrangler.toml:**
```toml
[triggers]
crons = ["0 17 * * *"]
```
At 5 PM UTC every day, Cloudflare fires the `0 17 * * *` cron. The Worker loads the registry, finds the trading module's handler, executes `trimTradesHandler`, and logs the number of deleted rows.
## Worked Example: Stats Recalculation
Imagine a leaderboard module that caches top-10 stats:
```js
export default {
name: "leaderboard",
init: async ({ db, sql }) => {
// ...
},
crons: [
{
schedule: "0 12 * * *", // noon UTC daily
name: "refresh-stats",
handler: async (event, { sql, db }) => {
if (!sql) return;
// Recompute aggregate stats from raw data
const topTen = await sql.all(
`SELECT user_id, SUM(score) as total_score
FROM leaderboard_plays
GROUP BY user_id
ORDER BY total_score DESC
LIMIT 10`
);
// Cache in KV for fast /leaderboard command response
await db.putJSON("cached_top_10", topTen);
console.log(`[cron] refresh-stats: updated top 10`);
},
},
],
};
```
Every day at noon, the leaderboard updates its cached stats without waiting for a user request.
## Crons and Cold Starts
Crons execute on a fresh Worker instance (potential cold start). Module `init` hooks run before the first handler, so cron handlers can safely assume initialization is complete.
If `init` throws, the cron fires anyway but has `sql` and `db` in a half-initialized state. Handle this gracefully:
```js
handler: async (event, { sql, db }) => {
if (!sql) {
console.warn("sql store not available, skipping");
return;
}
// proceed with confidence
}
```
## Adding a New Cron
1. **Declare in module:**
```js
crons: [
{ schedule: "0 3 * * *", name: "my-cron", handler: myHandler }
],
```
2. **Add to wrangler.toml:**
```toml
[triggers]
crons = ["0 3 * * *", "0 17 * * *"] # keep existing schedules
```
3. **Deploy:**
```bash
npm run deploy
```
4. **Test locally:**
```bash
npm run dev
# in another terminal:
curl "http://localhost:8787/__scheduled?cron=0+3+*+*+*"
```
## Monitoring Crons
Cron execution is logged to the Cloudflare Workers console. Check the tail:
```bash
npx wrangler tail
```
Look for `[cron]` prefixed log lines to see which crons ran and what they did.