Inside the Object Database
The root table, persistence, addressing and why everything in Frontier is an outline.
Every explanation of Frontier eventually arrives at the same sentence: it has an object database at its core. That sentence is true and almost entirely unhelpful, because the word database brings the wrong picture — tables of rows, a query language, a connection you open and close.
The object database is closer to a filesystem that never sleeps, whose files are typed values, whose folders are tables, and in which your program is one of the files. This article is a working tour: how it is laid out, how addressing really behaves, what persistence costs, and the failure modes that only appear after several years of uptime.
The root, and what lives in it
There is one tree. At the top sits the root table, and everything — code, data, preferences, window positions, the contents of the editor you are typing in — hangs beneath it. The top-level divisions have been stable for decades:
| Table | Holds | Yours to modify? |
|---|---|---|
system | The runtime's own verbs, types, temporary state and error handlers. | No. Changes are lost on upgrade. |
user | Your preferences, your scripts, your suites, your data. | Yes. This is where your work belongs. |
workspace | Scratch work and experiments in progress. | Yes, and treat it as disposable. |
scratchpad | Intermediate values, especially from scripts. | Yes, but never for anything that must survive. |
suites | Installed third-party suites. | Only through the suite's own installer. |
websites | Site content and templates, where the web framework is used. | Yes. |
The first rule of living with the ODB
Your code and your data go under user, in a table named for your project. Everything
else is either the runtime's or temporary. A suite that scatters nodes across four top-level tables
is a suite nobody will be able to uninstall.
Addressing: dots, at-signs and carets
An address is a path: user.myTool.data.lastRun. Prefix it with @ and you have
a value referring to that node; suffix an address expression with ^ and you have the
value at it. The confusion clears the moment you stop reading @ as decoration and
start reading it as “the address of”.
local (adr = @user.myTool.data.lastRun); «an addressType value
local (when = adr^); «the date stored there
adr^ = clock.now (); «write through the address
«Computed names: the ODB as a dictionary.
local (key = "2026-09-11");
if not defined (user.myTool.data.runs.[key]) {
user.myTool.data.runs.[key] = 0};
user.myTool.data.runs.[key]++;
«Walking a table without knowing its keys in advance.
local (name);
for name in user.myTool.data.runs {
msg (name + " = " + user.myTool.data.runs.[name])}
Two behaviours surprise newcomers, and both are consequences of the same design. First,
defined() is the gate for almost everything, because reading a node that does not exist is an
error rather than a null. Second, assigning to a path whose parents do not exist fails — the ODB
will not silently create intermediate tables for you. Hence the if not defined … new(tableType, …)
idiom that appears in every installer ever written for it.
Persistence, and what it actually costs
There is no save. Assign to a node and the value is in the database; the runtime handles flushing to disk. This is the feature that makes the system feel magical for a week and then teaches you its one real lesson: every write is a write.
A loop that updates a counter node a hundred thousand times has performed a hundred thousand persisted mutations. The same loop against a local variable, writing the total once at the end, performs one. The difference in a long-running system is not a micro-optimisation; it is the difference between a database file that stays compact and one that grows all week.
«Wrong: 100,000 persisted writes, and a fragmented database.
on tallySlowly (adrRows) {
user.myTool.data.total = 0;
local (row);
for row in adrRows^ {
user.myTool.data.total = user.myTool.data.total + row.amount}}
«Right: one persisted write, and it is atomic from any reader's view.
on tallyQuickly (adrRows) {
local (total = 0, row);
for row in adrRows^ {
total = total + row.amount};
user.myTool.data.total = total;
user.myTool.data.totalAt = clock.now ()}
Reads are cheap, traversals are not
Reading a node you already have the address of is fast. Resolving a long dotted path repeatedly inside a loop is not — each evaluation walks the tree from the top. Hoist the address out of the loop and dereference it inside. In audits this single change routinely accounts for a third of the runtime of a report-generating script.
Tables, records, outlines: choosing the right container
- Table — a persistent node with named children, arbitrarily nested. The default choice. Use it for anything that must survive a restart or be addressable from elsewhere.
- Record — an in-memory name-to-value map. Use it for the return value of a verb, for a request, for anything transient. Cheap, and it disappears when the script ends.
- List — ordered, in memory, indexed from one. Use it when order is the point and the collection is transient.
- Outline — hierarchical text with expand and collapse state. The native form of documents, scripts and structured notes, and the reason the editor is an outliner.
The commonest design error is using a table where a record belongs: building a return value in
scratchpad and handing back its address. It works, and it leaves persistent debris, is not
thread-safe if two callers race, and makes the verb impossible to reason about. Return a record.
«Leaves persistent state behind and races with itself.
on summariseBadly (adrRows) {
new (tableType, @scratchpad.summary);
scratchpad.summary.count = sizeOf (adrRows^);
return (@scratchpad.summary)}
«Transient, thread-safe, self-documenting.
on summarise (adrRows) {
local (total = 0, row);
for row in adrRows^ {
total = total + row.amount};
return ({
"count": sizeOf (adrRows^),
"total": total,
"average": total / sizeOf (adrRows^),
"at": clock.now ()})}
Concurrency without locks — almost
Cooperative threading means a script holds the runtime until it yields, which removes most of the races you would expect. Most, not all: any script that yields in the middle of a multi-node update has published a half-finished state to every other thread. Network calls, file writes and explicit sleeps all yield.
The remedy is a discipline rather than a mechanism. Compute everything into locals, then perform the writes in one unyielding burst. Where a genuinely long operation must appear atomic, write to a shadow table and rename — a rename is a single mutation.
on rebuildIndex (adrSource) {
local (adrTemp = @user.myTool.data.indexBuilding);
if defined (adrTemp^) {
delete (adrTemp)};
new (tableType, adrTemp);
«Slow work happens here; readers still see the old index.
local (adrItem);
for adrItem in adrSource^ {
adrTemp^.[nameOf (adrItem^)] = index.entryFor (adrItem);
thread.sleepFor (0.001)};
«One mutation makes the new index live.
if defined (user.myTool.data.index) {
delete (@user.myTool.data.index)};
table.rename (adrTemp, "index");
return (true)}
Operating it for years
- Back up the database file, verified. A backup that has never been restored is a hypothesis. Restore one quarterly, into a scratch environment, and open it.
- Watch the file size trend. Steady growth in a system whose data is not growing means write churn — usually defaults being rewritten on every call, or a log accumulating in a node nobody prunes.
- Prune with a scheduled agent. Retention has to be a script, because manual cleanup is a thing that stops happening the month the person who did it leaves.
- Never store secrets in plain nodes. The database file travels in backups, gets copied to laptops for debugging, and is readable by anyone who can open it.
- Keep a
readmenode inside each suite table. Documentation that lives with the code survives; documentation in a wiki does not. - Audit for orphans annually. Nodes referenced by nothing accumulate, and they are the hardest part of any later migration precisely because nobody can say whether they matter.
The migration connection
Everything above is also the inventory checklist we run at the start of a migration: what is here, what references it, what is merely residue. A tidy database migrates in weeks. An untidy one spends the first month being understood — which is time you pay for either way, just later.
Why the idea deserved to win
The object database collapsed a stack that most environments still keep separate: source files, a build artefact, a configuration format, a serialisation layer, a key-value store and a session cache. One addressable, typed, persistent tree does all of it, and the resulting programs are markedly shorter because an entire category of plumbing simply does not need to be written.
The idea arrived early and the industry took a different route, but every developer who has since described a document database, a reactive store or a single-tree configuration system has rebuilt some part of it. Understanding the original is a good way to see which parts were essential and which were merely of their time.
Take this away
- One tree: code, data and state all addressable and persistent
defined()before you read; the ODB creates no parents for you- Every assignment is a persisted write — accumulate in locals
- Return records, not scratchpad addresses
- Build aside and rename to make a long update look atomic