File Quotas
A worked use of those four points, and the reason reserve exists at all.
Why four points and not one. An upload is not atomic. Data writes are free of read-modify-write races — nothing awaits between reading the old value and writing the new one. A streaming upload awaits constantly, so two uploads can both pass a root.usage[uid].bytes + file.size < quota rule before either finishes, and neither sees the other. The fix is to book the cost before the bytes rather than after. Because increment() is synchronous and atomic, a reservation taken this way is race-free even though the upload around it is not.
An app therefore writes exactly two accounting handlers — one adding ctx.file.size, one subtracting it — and every path reconciles itself.
// app.js — the cost of an upload is the DELTA. An upload to a path that
// already holds a file REPLACES it, so it only costs the difference.
const d = (c) => c.file.size - (c.existing?.size ?? 0);
addFileTrigger("reserve", ["uploads"], (c) =>
increment(["usage", c.path[1], "bytes"], d(c)));
addFileTrigger("release", ["uploads"], (c) =>
increment(["usage", c.path[1], "bytes"], -d(c)));
// Deletions give the space back.
addFileTrigger("remove", ["uploads"], (c) =>
increment(["usage", c.path[1], "bytes"], -(c.existing?.size ?? 0)));
…and the rule that spends it. data is the existing file's stat, so subtracting it keeps the check consistent with the delta the triggers book:
"uploads": {
"$uid": {
"fileRead": "admin.uid == $uid",
"fileWrite": "admin.uid == $uid",
"fileValidate":
"(root.usage?.[$uid]?.bytes ?? 0) + file.size - (data?.size ?? 0) <= 5e8"
}
}
Usage lives in the data tree as an ordinary record, so you can inspect it, correct it, subscribe to it, and render a meter from it — and a fileValidate rule reads it live. Nothing here is special-cased by the engine.
Book the difference, not the size. This is the mistake that looks correct and fails slowly. An upload to a path that already holds a file replaces it, so it costs file.size - existing.size. Charging the full size leaks the replaced file's bytes on every overwrite, and usage climbs until the quota locks the user out of space nothing is occupying. Note the optional chaining too — a fileValidate that throws on a missing root.usage denies, so write root.usage?.[$uid], never root.usage[$uid].
The release/reserve swap at the end of an upload is not bookkeeping fussiness. It is what stops an upload's own reservation from counting against it: a rule reading usage + file.size <= quota must see usage without this upload in it, or a 2 KB file against a 3 KB quota fails its own second check. Release, check and re-reserve all run synchronously with no await between them, so no other upload can interleave.