Trivial until it needs state
A scheduled task is a one-liner until it has to touch data. Then the machine running the cron needs credentials to a database somewhere else, it has to remember where the last run finished, and it has to survive failing halfway through without processing everything twice.
What started as a cron expression turns into an architecture: a scheduler, somewhere to run the job, a database connection across a network boundary, and a way to track what the last successful run actually completed.
Codehooks runs the schedule inside the same deploy as the data it works on. The cursor lives next to the records, so a failed run can resume instead of starting again.
Where the complexity actually accumulates
| Cron host plus an external database | Codehooks |
|---|---|
| Credentials crossing a network boundary | Same deploy as the data |
| Cursor state kept somewhere else | Cursor in the same store |
| A partial failure means starting over | Queue-backed, so a run can resume |
| Scheduler and runtime billed apart | Both included |
| Logs in a third place | Logs beside the run |
A nightly reconciliation, complete
The whole thing, including the cursor. Notice there is no database connection string, no scheduler to configure, and no second service in the file.
import { app, Datastore } from 'codehooks-js';
// Every night at 03:00
app.job('0 3 * * *', async (req, res) => {
const db = await Datastore.open();
// No cursor on the very first run
const last = await db.get('lastRun');
const since = last || new Date(0).toISOString();
// One queued job per record that never synced, at any age
const { ticket } = await db.enqueueFromQuery('orders', {
synced: false
}, 'resync');
console.log(`queued ${ticket.count} orders since ${since}`);
// Only move the cursor once the run has succeeded
await db.set('lastRun', new Date().toISOString());
res.end();
});
export default app.init();
Questions worth asking first
Starting with the one where a free scheduler is the better answer
GitHub Actions runs my cron for free. Why would I move?
What happens if a run overlaps the previous one?
Can I trigger a job manually?
schedule.run({}, 'reconcile') from an ordinary endpoint, which gives you a manual trigger without duplicating the logic.Do failed items retry automatically?
maxRetries setting per step and persists state between them.What if the job takes longer than expected?
Generated code has less to misconfigure here
The schedule, the cursor and the data sit in one file, so there is no cross-service wiring for a coding agent to get subtly wrong. That is the usual failure mode when generated code has to span a scheduler, a network boundary and a separate database.
Set up your coding agent (skill, MCP server and the coho prompt reference) →
Where this usually leads
Scheduled jobs are rarely the whole problem. These are the two places they normally sit.
Put the schedule next to the data
Cron, queues and a database in a single deploy, so a nightly job is a function rather than an architecture.
Data stored in the EU