עברית
Motion
AccessibilityPrivacy
6 min readSystems · DevOpsRead it in Hebrew

Let visitors break my demos: an undo journal that resets them three hours later

How my live demos undo visitors' changes three hours later, with SQLite triggers and one transaction, on Cloudflare's free plan.

MobileSign, the project this post is about

Most web projects on my site have a live demo, and in most of them you can change anything: add a project, delete a warehouse, check people in, rename a team. I want it that way. A demo you can only look at doesn’t show much. The real logic, the permissions and the rules only show up when you push on them.

The catch is that the next visitor finds whatever the last one left behind. So every demo needs to put itself back. In October 2026 I built one shared module that does it for six of them: three hours after the first change, the next visit quietly returns the demo to its sample data before it is answered. This post is about how it works, and about the three cases that almost broke it.

The problem

The demos run on Cloudflare’s free plan. Each one is a Python Worker with a D1 database, which is SQLite underneath, filled by the app’s own population script. The free plan shaped every decision. As of October 2026:

  • Five scheduled jobs per account. I have seven demos with a database. A cron job per demo doesn’t fit.
  • 100,000 rows written per day, across all demos together. Reloading MobileSign’s sample data alone would write an estimated 76,000 rows, counting each row’s index entries. A full reload of every demo every few hours is out of the question.
  • A short CPU budget per request. Heavy pages are already cached so they fit.

Cloudflare’s documentation is unclear on some limits, such as how many database calls one request may make. I treated the strictest reading as the rule.

What I tried first

Before this, the demos reset the blunt way. Two of them replayed their whole seed once a day from a scheduled job. That used up scheduled jobs I didn’t have to spare, and it wrote every row of the sample data every time, even when nobody had touched the demo.

The Ta’az Digital Platform’s demo still does it that way: a scheduled job every three hours puts its data back. It is one demo, so it can have one job. For the other six I needed something that costs nothing when nobody visits, and very little when someone does.

What worked: an undo journal

The idea is to write down only what visitors change, and to undo only that.

Triggers record the first change to each row. For every app table, a build step generates a journal table and four SQLite triggers. The first time a visitor updates or deletes a row, the trigger saves the row as the seed left it. When a visitor adds a row, the trigger notes its key. A second change to the same row adds nothing, so the journal stays small however long someone plays. Each changed table is also listed once, with the time of its first change.

Here is one of them, generated for a made-up item table, with comments added:

CREATE TRIGGER "demo_undo__item__bd" BEFORE DELETE ON "item"
WHEN NOT EXISTS (SELECT 1 FROM "demo_undo_guard")
BEGIN
  -- save the row as it was, unless it is already saved or a visitor added it
  INSERT INTO "demo_undo__item" ("demo_kind", "id", "warehouse_id", "label")
  SELECT 0, OLD."id", OLD."warehouse_id", OLD."label"
  WHERE NOT EXISTS (SELECT 1 FROM "demo_undo__item" WHERE "demo_kind" = 1 AND "id" = OLD."id")
    AND NOT EXISTS (SELECT 1 FROM "demo_undo__item" WHERE "demo_kind" = 0 AND "id" = OLD."id");
  -- note that this table changed, once, with the time
  INSERT INTO "demo_undo_dirty" (name, at)
  SELECT 'item', CAST(strftime('%s', 'now') AS INTEGER)
  WHERE NOT EXISTS (SELECT 1 FROM "demo_undo_dirty" WHERE name = 'item');
END;

Cascades count too. When deleting a warehouse deletes its items through ON DELETE CASCADE, the same triggers fire for the items, so they come back with it.

The reset is one transaction. Before a Worker answers a request, it asks the database one small question, throttled in memory to at most once a minute per isolate: is the oldest change three hours old? If it is, the reset runs right there, as a single D1 batch. A batch is one transaction, so a visitor never sees half a reset. In order, it:

  1. switches the triggers off for its own writes;
  2. defers foreign-key checks to the end of the transaction;
  3. deletes the rows visitors added, children before parents;
  4. puts the saved rows back, parents before children: it updates the rows that are still there and inserts the ones that are gone;
  5. empties the journal and switches the triggers back on.

It touches only the tables that changed: four statements per table and four more. For two toy tables the whole batch reads like this:

INSERT OR IGNORE INTO "demo_undo_guard" (id) VALUES (1);
PRAGMA defer_foreign_keys = ON;
DELETE FROM "item" WHERE "id" IN (SELECT "id" FROM "demo_undo__item" WHERE "demo_kind" = 1);
DELETE FROM "warehouse" WHERE "id" IN (SELECT "id" FROM "demo_undo__warehouse" WHERE "demo_kind" = 1);
UPDATE "warehouse" SET "name" = j."name" FROM "demo_undo__warehouse" j
  WHERE j."demo_kind" = 0 AND "warehouse"."id" = j."id";
INSERT INTO "warehouse" ("id", "name") SELECT j."id", j."name" FROM "demo_undo__warehouse" j
  WHERE j."demo_kind" = 0 AND NOT EXISTS (SELECT 1 FROM "warehouse" WHERE "warehouse"."id" = j."id");
-- ...the same two statements for "item"...
DELETE FROM "demo_undo__warehouse";
DELETE FROM "demo_undo__item";
DELETE FROM "demo_undo_dirty" WHERE name IN ('warehouse', 'item');
DELETE FROM "demo_undo_guard";

No scheduled job is involved. A demo nobody visits costs nothing. A demo someone played with is reset by the first visit after the three hours.

The module reads each demo’s tables, keys and foreign keys from the seed’s own SQLite schema, so it fits every demo without hand-written lists. MobileSign has 20 tables and about 13,900 sample rows. MILESTONE has 16 tables and 64 triggers, T100 has 10 tables and 40 triggers, and the Hamal web app has 6 tables and 24 triggers. Measured locally, a T100 reset took about 26 ms.

The three cases the reviews caught

I had each demo’s integration reviewed separately, and the reviews found real bugs. Three of them changed the design.

Row counts. D1 counts the rows the triggers write in its reported change count. A one-row update now reported 3. The apps’ database layers trusted that number, so one app decided its update had hit the wrong rows and failed every single edit. The fix was to ask SQLite itself: each write now goes to D1 in one batch together with SELECT changes(), which never counts trigger writes. It is still one round trip.

Swapped values. A visitor can swap a unique value between two rows. In MobileSign, promoting a sub-manager to primary manager demotes the old one, which is exactly such a swap. The reset then failed on the unique constraint. It failed cleanly, because it is one transaction, but it failed every time after that, so the demo would never reset again. Now, when the batch fails on a unique constraint, it runs again with the rows it is about to put back moved out of the way first: it deletes them, so the insert that follows puts them back whole, or, in a table that others refer to with an ON DELETE rule, it gives their unique columns a throwaway value that cannot clash. The module calls this “parking”. Locally, a parked T100 reset wrote 250 to 252 rows and took 34 to 39 ms.

A change during the reset. A visitor’s change could land between the check and the reset. The reset used to empty the whole list of changed tables, so that change would lose its mark and stay forever. Now the reset clears only the marks of the tables it put back.

How I know it works

I didn’t want to trust a reset by looking at it. The build step that writes the triggers also runs a check on plain SQLite: it loads the seed, makes visitor-like changes (edits, deletes with their cascades, new rows, rows replaced over existing ones, a moved key, two rows trading a unique value), runs the reset exactly as the Worker does, and requires the data to equal the seed again, with foreign keys intact and the journal empty. It runs several rounds with different changes and resets twice in each round, because the journal must work again after a reset. If any table differs, the build stops and prints the first extra and missing rows.

Then each demo passed a live test: I made a change, backdated its timestamp so it was due, and watched the next visit put the sample data back.

What I would do differently

I would make every app take its row counts from SQLite from day one. Trusting the database driver’s change count was fine until something else wrote in the same statement, and then it broke every edit at once.

I would also test against the real hosted database earlier. Local emulation doesn’t enforce every free-plan limit, so some questions, such as how many calls one request may make, could only be answered live. And I would keep every free-plan fact dated in the code comments, because the limits move.

Where it lives

The module is one Python file, copied unchanged into each demo’s deploy folder, next to a generated list of that demo’s tables. It runs in six demos, including MobileSign, MILESTONE, T100, the Hamal Monitoring App and the AKA Attendees Tracker, which I redeveloped from the v0.1 Noam Shadmi built. Every one of them shows the same line under its demo button: sample data, and changes are undone about three hours later. Now you know what happens behind it. Go ahead and break something.

The projects

  • Systems
  • DevOps
Next post
Verification, not trust: a notice board built in a day, and measured before it shipped

Chapters