Data Layer
Published
An embedded database handle is configured entirely through an options object, and every switch in it is a safety property: whether the connection can write, whether referential integrity is enforced, whether the SQL dialect is strict, whether native extensions may load. We opened five handles with non-default switches, renamed the option names, and watched which protections survived.
The setup
The sample uses node's built-in node:sqlite module, which needs no dependency and ships with node 24. It seeds a two-table ledger, then opens the same file five more times with a different switch set each time: a reporting handle pinned readOnly, an import handle that deliberately relaxes enableForeignKeyConstraints, a legacy handle that enables double-quoted string literals, a handle that allows extension loading, and a deferred handle opened with open: false.
Every one of those values is non-default. Node opens a database read-write, with foreign keys enforced, with the strict SQL dialect, with extension loading disabled, and it opens eagerly. That matters: renaming an option whose value already equals the runtime default cannot change anything, so a measurement built on default values would report a comforting row of no-changes that means nothing.
Protection alone, across all five presets, reproduced the unprotected output exactly. As with every area in this series, the changes below appear only when member renaming is pointed at the names the database module reads.
The reporting connection that started writing
The clearest result of the pass. With readOnly renamed, the handle that the code asks to be read-only opens read-write, because the absence of the switch is a request for the default. Our reported verdict moved from readonly-write=refused:ERR_SQLITE_ERROR to readonly-write=ACCEPTED, and the ledger value the report then read back moved from 129900 to 1.
Note the shape of that failure. The UPDATE in the sample is the kind of statement that exists in a reporting codebase by accident: a scratch query, a fixture left in a helper, a shared function that writes when it is called with the wrong argument. The read-only flag is what makes that harmless. Take the flag away and the write is not just permitted, it is committed, and the next read returns the corrupted value with no indication that anything unusual happened.
There is no error, no warning, and no signal at the database layer, because from SQLite's point of view a perfectly ordinary write arrived on a perfectly ordinary connection. The only party who knew this connection was supposed to be read-only was the option name.
The permissive switch that failed the other way
The import handle turns foreign keys off on purpose, which is what a bulk loader does when rows arrive before the rows they reference. Renaming enableForeignKeyConstraints removed the request and node reverted to its default, which is enforcement on. The import that had been succeeding failed on its first statement: bulk-import=ok rows=1 became bulk-import=FAILED:ERR_SQLITE_ERROR rows=0.
That is the mirror image of the read-only result, and the pairing is the useful lesson. When your option restricts something, losing it fails open and you find out from an incident. When your option permits something, losing it fails closed and you find out from a stack trace. The same transform, the same mechanism, and opposite consequences decided entirely by which direction your value points.
Two smaller arms behaved the same way. Renaming enableDoubleQuotedStringLiterals turned a legacy query into no such column: "pending", and renaming allowExtension made the extension gate refuse with a plain message. Both are loud, both stop the process, and both are much easier to live with than the silent one.
An option name that is also a method name
The deferred handle is opened with open: false and then opened explicitly a moment later. Renaming open hit both: the constructor lost its option, so the handle opened eagerly, and the later deferred.open() call became deferred._0x1 is not a function.
This keeps happening across the series and it is worth stating plainly: a member pattern matches names, not roles. It cannot tell the option key open from the method open, or a configuration flag from a standard library method that happens to share its spelling. Earlier passes caught the same collision on filter, where a file-system option pattern also matched Array.prototype.filter.
Here the collision is the good outcome, because a TypeError at the call site is immediate. The uncomfortable case is when a name collides with something used far away in a rarely-exercised branch, and the first evidence arrives from a customer.
The column name that was not renamed, and the read that was
One more arm rounds out the picture. The sample reads a row property that corresponds to a column named in the SQL text. Renaming that name left the SQL string untouched, because a string literal is not a member, while the property read became a renamed identifier. The reported value moved from a number to undefined.
This is the same shape documented for object-relational mappers: the query still runs, the row still comes back with all of its data, and only the read misses. It is worth repeating here because embedded databases invite raw SQL far more than a server-side ORM does, so the two halves of the contract sit in the same file, look symmetrical, and are not.
The practical consequence is that a query layer needs the same treatment as an options object: either exclude the column names from renaming, or read rows through a mapping step that uses bracket access with string keys you control.
What an audit should actually ask
Listing the options a handle is constructed with is not enough, because a list can be perfectly accurate and still describe a database that no longer behaves that way. The question that separates the two is which options carry a non-default value, since those are the only ones whose loss is observable.
For an embedded database that list is short and every entry is a safety property. Whether the handle is read-only. Whether referential integrity is on. Whether the dialect is strict. Whether extensions may load. If your codebase sets any of them, put those names in the member-renaming exclusion list and move on.
Then add one runtime assertion per property that matters. A read-only handle can be proved read-only by attempting a write inside a try block at startup and requiring the failure. Assertions like that survive renaming, run in the shipped artifact, and are the only kind of check that reflects what the code actually does rather than what it says.
Where this leaves protection
None of this is an argument against protecting data-layer code. The default profile does not rename members at all; the transform is opt-in and scoped by a pattern you write. Every result in this article required pointing that pattern directly at names that a database module reads.
It is an argument for treating an options object as an external interface. The keys are read by somebody else's parser, and the value of a missing key is decided by their defaults, not yours. That is the same rule that governs JSON payloads, HTTP headers, and configuration files, and it applies to a local database handle just as firmly.
The engine gives you the scoping tool. A regular expression that matches only the property names your own code both writes and reads is safe by construction, and everything in this series has been a demonstration of what happens when that pattern is drawn wider than that.
Frequently asked questions
Does obfuscation change how an embedded database behaves?
Not by itself. Protection alone reproduced our sample's output exactly on all five presets. Behaviour changed only when member renaming was pointed at the option names that node:sqlite reads.
What happens if the readOnly option is renamed?
The handle opens read-write, because the absence of the switch is a request for the default. In our run a write that had been refused with ERR_SQLITE_ERROR was accepted and committed, and the value read back afterwards was the corrupted one.
Why did relaxing foreign keys break instead of failing open?
Because that option is permissive rather than restrictive. Removing it reverts to node's default, which is enforcement on, so a bulk import that depends on deferred references fails immediately. Restrictive options fail open when renamed; permissive ones fail closed.
Can a member pattern really match a method name?
Yes. Our pattern for the option key open also matched the handle's open method, producing a TypeError at the call site. A pattern matches spellings, not roles, so a short generic name is the riskiest kind to include.
Are column names affected?
The names inside SQL text are string literals and are never renamed, but the property read that consumes the returned row is a member access and can be. The result is a row that arrives intact and a read that returns undefined.
How do I decide what to exclude?
Start from the options you pass a non-default value to. For an embedded database that is usually a handful of safety switches: read-only, foreign keys, dialect strictness and extension loading. Excluding those names costs nothing measurable in protection strength.
Is there a check that survives renaming?
Yes. Assert the property at runtime rather than inspecting configuration: try a write on a connection that is supposed to be read-only and require the failure. String literals and control flow are unaffected by member renaming, so an assertion like that keeps working in the shipped build.
Related reading