Architecture

One SvelteKit application, one SQLite file, and two small clocks beside it — timers that ask the app to deliver due reminders every minute and to send the weekly review mail every hour. The clocks own no logic and no data: each is a POST to the app's own job endpoint, so the app is still the only thing that thinks. Everything below follows from that shape: there is no queue, no cache to invalidate and no second service to be out of step with — a companion that dies means late reminders, never wrong data, and Settings → Instance shows whether each is running.

Where a request goes

browser ─▶ hooks.server.ts ─▶ route (+page.server.ts / +server.ts) ─▶ service ─▶ SQLite

hooks.server.ts holds the rules that must not be forgotten by a route written next year: the registration mode, the rate limits, the session, the account holds, and the guard that walks a signed-out write to the login page. They are in one file for the reason they exist — a rule each route has to remember is a rule that will be missed by one of them.

Routes are adapters. A +page.server.ts reads the form, calls one service function and turns what it throws into a fail(); a +server.ts under /api does the same and answers JSON. They hold no business rules and no SQL, which is what lets the page and the API agree — they call the same function.

Services own the database. Everything under src/lib/server/services/ is where the rules live: what is valid, what a category may be renamed to, what happens to occurrences when a block is deleted. Nothing else runs a query.

Every service function takes a Ctx — the account, the current time and the timezone — as its first argument. It is what makes "which day is today" answerable for somebody in São Paulo while the server is in Frankfurt, and it is what makes the whole layer testable without a browser.

One route, two instances

Ontoplano runs served from a box and, on a phone, entirely inside the device with no server anywhere. Both run the same files: a route's +page.server.ts is loaded by SvelteKit on a server and imported by a worker on a device, where the same load and the same actions run against SQLite compiled to WebAssembly. There is no second implementation and nothing to keep in step.

The constraint that makes it work is that such a file may not reach for $lib/server, for $env, or for Node. Anything that genuinely belongs to a deployment — whether registration is open, whether this instance collects error reports, what it charges — goes through the host seam in services/host.ts, whose defaults are what is true on a device and whose real answers a server binds at boot. A handful of screens are about a deployment and nothing else; those are named in one list and say so plainly rather than half-working.

Layouts are the exception, and honestly so: +layout.server.ts answers questions about a deployment, so where the two instances differ there is a layout.isolated.ts beside it.

Two doors, one rule

The app has two write surfaces: form actions, and the HTTP API. They are deliberately not two implementations. An action and an endpoint that do the same thing call the same service function, so a rule cannot be enforced on one and forgotten on the other — see Pages and actions and the API, which are two views of the same layer underneath.

Where each kind of truth lives

Kind Where Why there
What is valid the service both doors ask the same function
What must never be skipped hooks.server.ts a rule to be remembered is a rule to be missed
The shape of the data drizzle/ migrations the snapshot is what the data model page is built from
What an instance allows config.toml it is the operator's decision, not the code's
What an account prefers user_settings it is theirs, and it travels in the export
Why a line is the way it is the comment above that line it moves with the code, so it stays true
A picture a column in the database one file you can copy, export and walk away with

Pictures live in the database

The bytes of an uploaded picture are a blob column in media, not a file in a directory beside it. The trade is deliberate: what this app promises is that your data is one SQLite file you can copy, export and walk away with, and a media directory makes that two things that have to travel together — a backup that took one of them and not the other looks exactly like a backup. Under a megabyte a row SQLite reads a blob faster than the filesystem opens a file, and the ceiling is the operator's ([media] max_kilobytes), so the file cannot quietly become unmanageable.

Three rules guard what comes in, and all three are in services/media.ts:

Importing a folder adds two more. The path the browser sends is a string the client wrote, so albumNameFor strips it back to segments and refuses to treat it as a path: no .., no separators, no control characters, and the em dash that means "inside" is taken out of each folder's own name. And a folder is many pictures, so it is sent in batches sized to what the server will read in one body ([media] import_files bounds the tree, BODY_SIZE_LIMIT less its framing bounds the batch) — a whole tree in one POST is refused by the Node adapter before this app runs, with a plain 413 whose body no page can read.

Serving is /media/<id>, scoped by owner in the WHERE, with nosniff and an immutable cache. Inside somebody's writing a picture is markdown pointing at that address, and the renderer refuses any other address: an external one would tell a third party who is reading, and when.

On a device there is no route to serve it, and an <img> is the one request the fetch bridge cannot see — a browser loads an image itself and never consults window.fetch. Nor can the address be swapped for a blob: one in the components, because pictures also arrive inside rendered markdown. So the service worker answers /media/<id> by asking the open page, which asks the database worker: three hops, each between two parties already in the room. The same <img src="/media/3"> draws on both instances, which is what keeps the gallery one screen rather than two.

What is deliberately absent