Two functions that do nothing: how AI-built apps get indexes and cron jobs

Apps built on monday.com/vibe are written by a model — written, revised, and redeployed by an agent, prompt after prompt, usually for a user who will never open the code. That sounds like a code-generation problem. But the place the constraint bites hardest turned out to be infrastructure.

A real app eventually needs a database. Then indexes, because someone's task tracker grew to ten thousand rows. Then a cron job, because "email me a digest every morning" is the kind of thing people ask software for. And every mainstream way of getting those involves an extra artifact or an extra step: a migration to run, a schedule to register, a manifest to keep honest.

Why not just migrations?

To be clear about what the problem isn't: agents handle conventional config artifacts fine. An agent working in a repo with a migrations directory will write the migration — the convention is visible, the examples are right there, and the check loop fails if it doesn't. "The model forgets the second file" is not the argument.

The argument is simpler: one source of truth, because every additional artifact is a tax the agent pays on every turn.

An agent operating a system has to hold that system in context. A migrations setup is at least three things to hold: the current code, the append-only history that explains how the schema got here, and the state tracking which steps have run where. Every one of those is tokens to read, consistency to maintain, and a new way to be wrong — ordering mistakes, up/down asymmetry, history that contradicts the code after a rollback. None of that work is the feature. It's overhead the format imposes before the agent gets to do the thing the user asked for.

A declaration in the source collapses all of it to one artifact — the file the agent was already editing. The full infrastructure story of an app is readable from its current source, in one pass, with no history to reconcile against. Minimum tokens, minimum places to diverge, minimum mistakes.

The platform's own design keeps this honest. Migrations earn their ceremony when there are irreversible, data-touching transformations to sequence — and Vibe DB deliberately has none. Documents are schemaless JSON; the only infrastructure state that exists (indexes, schedules) is derived and rebuildable from a declaration at any time.

And once nothing needs replaying, two operations that are genuinely hard in a migration world become almost embarrassingly easy:

Rollback is redeploying the old source. That's it. The infrastructure reconciles to what that version declares — indexes the newer version added get dropped, schedules it removed come back. No down-migrations, which is fitting, because nobody has ever tested a down-migration. Vibe leans on this constantly: a bad generation is undone by rolling the app back a version, and the infrastructure follows the code without a separate cleanup story.

Duplication is pointing the same source at a fresh database. Vibe apps get copied all the time — templates, remixes, "make me one like that" — and each copy deploys the same source against its own empty Durable Object, where the same declarations materialize the same indexes and schedules from scratch. There's no chain to replay and no state table to carry over, because there's no state outside the source.

That's what makes the simple model sufficient, not just convenient: regenerate, roll back, duplicate — each reduces to "deploy the source, reconcile again."

It's a trade, and the cost should be named: schema evolution is unsolved on purpose. If the model renames a field, old documents keep the old key forever — nothing transforms them, and the generated read paths have to tolerate both shapes. That's the price of a system with no history. So far it's a price worth paying for authors that regenerate the code wholesale anyway.

So: the app's source code is the manifest. Declare what you need in the code you were already writing, and the deploy pipeline reads the code and makes the infrastructure match.

That's the pattern this post is about. First, the thing it configures.

Background: every app gets its own database

Vibe apps needed storage that isn't monday boards — game scores, form submissions, comments, the data apps accumulate. The shape we landed on: every generated app gets its own SQLite database, living in a Cloudflare Durable Object at the edge.

One database per app buys a lot. Tenant isolation isn't a WHERE account_id = ? clause that ten thousand queries must each remember — it's physical. An app can't read another app's rows because it can't reach another app's database. There's no shared instance for one runaway app to saturate, no connection pool to size, and deleting an app is deleting its database.

On top of the storage sits a deliberately small document API — Firestore-flavored, because the model already knows Firestore's shape and meeting its priors beats fighting them. Alongside it, a realtime layer (whose socket never carries data, only "this collection changed" — a post for another day) and a scheduler, because apps need to do things when no one is using them.

That's where the gap opens. A document store needs no schema — the model writes, reads, done. But indexes and cron jobs aren't data. They're infrastructure state that has to exist on the Durable Object before they do anything, and somebody has to tell the platform about them. In this system there is no somebody.

Two functions that do nothing

The app-facing answer is two functions with no behavior:

export const tasks = defineCollection<Task>({
  name: 'tasks',
  indexes: ['status', 'assignee'],
});

export const schedules = [
  defineSchedule({ name: 'dailyDigest', cron: '0 9 * * *', handler: sendDigest }),
];

defineCollection returns its argument. defineSchedule is the identity function. No runtime registry, no side effects, no network call — at runtime these declarations are inert.

They exist for a different reader. At deploy, the platform pulls the app's stored source from object storage — not the running app; the app doesn't need to be up, or even deployable — and parses it with the TypeScript compiler. It walks the tree for these declarations, extracts what infrastructure cares about (collection names and indexed fields, schedule names and timings), and reconciles the app's Durable Object to match. Indexes created if missing, dropped if removed. Schedules registered, the alarm re-armed.

Delete the defineSchedule line and redeploy: the cron job is gone. The code never called anything. The code is the record.

The harvest is deliberately dumb

Parsing arbitrary code for configuration sounds fragile, and it would be — so the extractor refuses to be clever, and each refusal is a policy:

Only exported, top-level declarations count. A defineCollection buried in a helper function or a commented-out example never registers a phantom index. If the app doesn't export it, it doesn't exist.

Only literals count. String literals, arrays of string literals. A computed value — indexes: getFields() — is dropped. The manifest is code, but only the boring subset of code participates.

Extraction never throws. A malformed manifest yields fewer declarations, not a failed deploy. The author is a model; it will occasionally write junk, and junk config must never stop the ship.

These three rules share a property that matters more than any of them individually: they're mechanically checkable. "Is it exported, top-level, and literal" is a question a linter answers in milliseconds — no model, no judgment. The same deterministic check loop that flags type errors while the agent works can flag "this defineCollection is dynamic, so it registers nothing" at write time, and the agent fixes it in the same turn. Rules simple enough for a dumb parser are rules simple enough to enforce as lint — which means the constraint teaches itself, instead of living in a documentation paragraph and hoping.

One more omission is deliberate: for schedules, the platform extracts the name and the timing and not the handler. The behavior stays in the app and runs in the app, when the platform calls back at fire time. The platform learns when; the app keeps what. The scheduler's entire worldview is names and timestamps.

Reconcile, don't accumulate

The subtle half of the pattern isn't reading the source. It's what "make the infrastructure match" means when the answer is less than last time.

This is desired-state replacement, not registration. Indexes declared last deploy but not this one get dropped. And the pipeline reconciles even to an empty set — there's a comment in the deploy path that earns its keep:

Always call (even with []) so a deploy that removed the last schedule clears the DO.

Without that line, cron jobs would be append-only: removable by no action the author could ever take, firing forever at an app that stopped expecting them.

The detail I'd point at in a design review is the tri-state read guarding the wipe. Before concluding "nothing declared," the pipeline distinguishes three situations that all look like an empty list:

  1. Retrieval succeeded and returned the app's generated files, but no manifest among them — authoritative. The author removed it. Clear the infrastructure.
  2. Retrieval succeeded but returned nothing at all — inconclusive. Something is off with the stored version. Touch nothing.
  3. Retrieval failed — caught, logged, state preserved.

"Empty because deleted" and "empty because we failed to look" are different facts, and collapsing them is how declarative systems delete production state. Most reconcilers learn that distinction from an incident. This one shipped with it.

Calibrated leniency

The failure posture isn't uniformly soft. It's tuned case by case, and the tuning is the actual design work:

  • Malformed entry? Skipped silently. Deploys survive model junk.
  • Too many declared indexes? Truncated to the cap, with a warning — not an error, because over-declaring is something the model often can't cleanly avoid, and a deploy shouldn't fail for it.
  • A genuinely invalid field name? Hard rejection. That's a real manifest bug the agent must fix, and a silent skip would bury it.
  • One bad schedule name in a set of ten? Filtered out before registration — because the alternative is the whole set being rejected, the best-effort deploy swallowing the rejection, and the app ending up with no cron at all instead of nine-tenths of one.
  • The entire reconciliation failing? The deploy proceeds, and the previous indexes stay untouched.

The last row states the invariant that makes the whole approach safe: harvested configuration is only ever allowed to degrade performance, never correctness, and never the deploy. A missing index is a slower query. A missing cron is a digest that's late until the next deploy. Neither is a wrong answer, and neither blocks shipping.

Nobody trusts the manifest — including the app

Fire time closes the loop with a symmetry worth noticing. When a schedule comes due, the platform calls back into the app over a secret-gated webhook, and the app dispatches by name to its own handler. That dispatch code treats the app's own manifest as untrusted input — it validates every entry, warns and skips anything malformed, and refuses to crash on its own author's output.

So both readers of the manifest — the deploy pipeline outside, the dispatcher inside — independently assume it might be garbage. That's the right posture when the author is a model. It was probably always the right posture.

The migration file, inverted

Step back and there's a lineage here. Rails migrations put the steps in a history file beside the code. Terraform moved desired state out of operators' heads and into its own repository and language. Each generation moved the truth closer to an artifact and further from a human's memory.

This is the next step on the same line: the truth moves into the only artifact that was ever going to be correct anyway — the app's own source, the thing the model writes with its full attention, the thing that survives every regeneration. Everything else is derived from it, by a reader that runs on every deploy.

For a conventional app the trade is arguable — migrations carry data transformations, and a history you can replay is worth its ceremony. But for apps that are regenerated, rolled back, and duplicated as a matter of course, the calculus flips: the only artifact that's always present, always current, and always authoritative is the source itself. Everything the agent needs to know fits in the file it's already reading. So derive everything from it, on every deploy — and let the model spend its tokens on the thing it was always going to do anyway: writing the code.

← All posts