Firebase Gotchas
One of the core promises of Lark is that if you've got an app running on the Firebase Realtime Database today, you can point it at Lark by changing a single line in your config and everything keeps working. The Firebase JS SDK connects to us directly and doesn't know the difference.
That's a simple thing to say and a much less simple thing to actually deliver. It means we don't get to implement the documented behaviour of the Realtime Database, we have to implement the real behaviour, including a whole pile of edge cases that aren't written down anywhere. Firebase has been running since 2012, and there are a lot of production apps out there quietly depending on quirks that their authors never knew existed.
I thought it would be fun to walk through a few that have caught us off guard while building Lark. These are all things we've had to build dedicated test suites around, and a couple of them are things we got wrong the first time and had to go back and fix.
Arrays
Firebase doesn't actually have an array type, which may seem surprising at first, since it will happily let you store an array and get one back out. But what's actually going on underneath the surface is a little more complicated and required real care to match.
When you send ["a", "b", "c"], what gets stored is {"0": "a", "1": "b", "2": "c"} -- a completely ordinary object that happens to have numbers for keys. What you get back when you read it is a decision that gets made at read time.
Here's the rule for that decision: return the node as an array if it isn't empty, every key is a plain non-negative integer, and the largest key is less than twice the number of keys. Otherwise, hand back an object.
That last condition is the one that trips up folks, so let's walk through it. Say you write a three-element array and then delete the middle element. Firebase can't store null anywhere (writing null to a path is how you delete it), so you don't get a null sitting in the middle of your data, you get {"0": "a", "2": "c"}. Two keys, the largest is 2, and 2 is less than 4, so you still get an array back: ["a", null, "c"]. That null is generated for you on the way out to fill the hole.
Now delete a few more. Take a five-element array, remove indices 0, 1, and 3, and you're left with {"2": "c", "4": "e"}. Largest key is 4, there are two keys, and 4 is not less than 4. Your array just turned into an object. That may seem very surprising, but it's behaviour that has existed in Firebase since the early days.
Honestly, earlier versions of Lark had this wrong. Our internal value type had a real array variant in it, and all of the code that walks down a path to perform a write only knew how to descend into objects. So if you had array data in your database and you wrote to a path like /arr/0/field, the write would clobber the entire array and leave you with only the piece you just wrote. Import an array, edit one field on one element, watch everything else vanish.
The fix was to stop having arrays internally at all. We store integer-keyed objects now, because that's what the data model genuinely is, and we do the array rendering only at the point where data goes out over the wire. Storage and presentation are two different concerns, and treating them as one thing is what caused the bug in the first place.
Sorting
Firebase has two different sort orders, and it turns out they don't agree with each other.
If you're sorting by value, either with orderByValue or with an orderByChild where some of the children don't have the field, values sort across types in a fixed sequence: null first, then false, then true, then numbers, then strings, then objects. That's why children that are missing your sort field always land at the front of the list instead of at the end, or in an error message.
If you're sorting by key, you get a completely different rule. Keys that look like 32-bit integers sort first and sort numerically, and everything else sorts after them lexicographically. So "9" comes before "10" under orderByKey, but "a9" comes after "a10", because at that point they're just strings and strings compare character by character.
"Looks like an integer" is also stricter than you'd probably guess. "01" is a string because of the leading zero. "-0" is a string. "007" is a string. "99999999999999" is a string, because it doesn't fit in an int32. Every one of those sorts into the lexicographic pile with the words rather than into the numeric pile with the numbers.
This is the same canonical-integer test that the array rule uses, which isn't a coincidence, and it's why {"01": "a", "02": "b"} will stay an object forever no matter how dense it looks. (The two rules aren't quite identical, though. Key sorting is fine with negative integers, and array coercion isn't.)
Security Rules Are a Javascript Program
Firebase rules look like JSON with some expressions sprinkled in, and it's very easy to read them as a small declarative language, but they aren't! They're Javascript expressions, evaluated by a Javascript engine, which means that all of Javascript's type coercion is baked in. When you're reimplementing that, "close enough" isn't good enough, because the gap between your semantics and Javascript's semantics is a security hole.
Here's the one I like best. A rule like newData.val().length >= 8 on a password field looks completely airtight. But .length in Javascript counts UTF-16 code units, not bytes and not characters. "é" has a length of 1 and takes up two bytes. "🔥" has a length of 2 and takes up four. We were originally returning Rust's str::len(), which is bytes, so every multi-byte string measured longer for us than it does in Javascript. Two fire emoji are 8 bytes, so they cleared that minimum, where real Firebase measures them at 4 and turns them away. The skew runs the other direction on maximums, too: a length <= 20 cap would have rejected input that Firebase accepts without complaint.
Number coercion was another key area to consider. Think about a .validate rule like newData.val() <= 100 on a field you expect to hold a number. What should happen when somebody writes a string there instead?
In Javascript, "abc" coerces to NaN, and every comparison involving NaN is false. So the validate fails and the write gets denied. That's the correct outcome, but it's correct mostly as a side effect of how Javascript handles NaN. Building a permission system on top of that is a little alarming when you think about it too hard, but it's what we've got. Our original evaluator converted strings to 0 instead. So 0 <= 100 was true, and the write went through.
Fixing it meant implementing Javascript's ToNumber and its relational comparison properly. Empty string is 0, numeric strings parse to their value, everything else is NaN, and NaN makes <, >, <=, and >= all return false rather than quietly falling back to some other ordering. We use a differential table in the tests that goes type pair by type pair, where the expected result in every row is what a real Javascript engine actually returns.
Two more rules behaviours that sometimes trip even long-time Firebase users up. First, .read and .write cascade downward and can't be taken back. If a parent grants write access, a .write: false on a child does absolutely nothing. Second, .validate doesn't cascade at all. Every level has to pass on its own, and validates are skipped entirely on deletes. Both of those are documented, but they're documented in different places, and it may not be your first assumption!
Event Ordering
This last one isn't about your data at all, it's about what the SDK does with your writes before the server has even seen them.
When you call update(), the Firebase SDK applies the change to its local view right away and fires your listeners, which is what makes your UI feel instant. It holds onto that optimistic write until the server acknowledges it, then throws it away and recomputes the view from canonical server state. So the ack is effectively a commit point. By the time the SDK sees it, all data events that the write produced need to have already arrived.
Get that ordering backwards and you get a genuinely confusing bug. We hit it with the blog demo in the Firebase quickstart. You wrote a post, the SDK sent a multi-path update at the root ({"/posts/<id>": ..., "/user-posts/<uid>/<id>": ...}, with paths as the keys), the post showed up in the UI immediately, the server acked, and then the post disappeared!. What was happening is that the server acked without echoing a data event back to the writer's own subscription on /posts. So the SDK dropped its optimistic write, recomputed from server state that didn't have the post in it yet, concluded the post had been removed, and fired child_removed. Reload the page and it was back, because now the read returned it.
There are two tests covering this now. One checks that a multi-path update fans out to the writer's own query subscription. The other one just walks the raw message log after a write and asserts that the put shows up at a lower index than the ack.
How We Test All This
Since the beginning we've known a key goal of Lark would be to match even this edge case behaviour as closely as possible, and there's a few things we've done to make sure it doesn't break in the future.
The first thing we do is run the mocha suite that Google wrote for @firebase/database-compat, unmodified, against a real Lark stack. We clone a pinned commit of firebase-js-sdk, build the package, bring up a temporary Lark, and point the tests at it. Anywhere we diverge from what the SDK expects, we'll see an error.
In addition to that, we have 22 integration test suites, each of which boots a real server and speaks the actual wire protocol, plus around 200 unit tests over the on-disk storage format. Many of these have been crafted after running Lark in production for several months and catching different behaviour than expected from apps migrated from Firebase.
Our hope at this point is that we've got most of these edge cases identified and fixed, but we're always on the look out for ones we might have missed, so if you're using Lark and notice something not working quite right on a project you migrated from Firebase, let us know at team@lark.sh and we'll investigate it.
Until next time, may your data flow, your rules compile, and your arrays behave as expected!