Developers
Database
On this page
This page describes the server's storage: the two SQLite databases, every table and which migration made it, how migrations run, and the conventions to follow when you change the schema. It is for developers who add a table or a column, or who need to read the data directly. When you finish it you will be able to write a migration that fits in, and know what happens on a server when the code and the database disagree.
Two databases and some files
var/ FRAME_VAR_DIR (default: next to app/; set var_dir in config.php)
├── frames.db Your decisions. The web user writes it; cron opens it read-only
├── sessions/ Admin sessions (directory mode 0700)
├── device.key Signing key for trusted-device cookies (mode 0600)
├── login.lock Lock that serializes password hashing
├── kiosk/<sha256>.apk Kiosk releases, named by hash
├── notes/<sha256>.<ext> Card covers and stamps, named by hash
└── photos/
├── files/<xx>/<sha256>.jpg and .thumb.jpg Stored photos and thumbnails
├── tmp/ Uploads and decoder scratch space (cleaned automatically)
└── .ingest.lock One writer at a time
cache/ FRAME_CACHE_DIR
├── cache.db Fetched data. Only cron writes it; the web user opens it read-only
└── fetch.lock Keeps two fetcher runs apart
Back up var/ (it holds frames.db, the photos, the card art and the kiosk releases) and keep the backup private. cache/ can be rebuilt by the fetcher.
frames.db |
cache.db |
|
|---|---|---|
| Holds | Frames, widget settings, Library records, admin users, albums, photos, cards, kiosk releases, rate-limit events | Fetched weather, calendar occurrences and photo album listings; fetch status |
| Writer | The web user (and the command-line tools) | The cron fetcher |
| Schema | Versioned by migrations | Created by the fetcher with CREATE TABLE IF NOT EXISTS, not versioned |
| If it is lost | Everything you configured is gone | The next fetch fills it again |
Every connection turns on PRAGMA foreign_keys = ON and waits up to 5 seconds for a lock. The code never switches to write-ahead logging, so the default rollback journal is used. That keeps a read-only reader working without write access to a side file.
Check-then-write sequences run inside db_tx(), which opens the transaction with BEGIN IMMEDIATE so that no other writer can slip in between the check and the write.
How migrations run
Migrations are the numbered files web/app/migrations/NNN_name.sql. bin/migrate.php applies them.
flowchart TD
start["php app/bin/migrate.php<br/>run as the web user"] --> read["Read PRAGMA user_version<br/>from frames.db, creating it if needed"]
read --> loop{"Another file<br/>numbered above it?"}
loop -->|yes| tx["BEGIN<br/>run the whole file<br/>PRAGMA user_version = N<br/>COMMIT"]
tx -->|"error"| fail["ROLLBACK<br/>print the error, exit 1"]
tx -->|"ok"| loop
loop -->|no| check{"user_version equals<br/>SCHEMA_VERSION?"}
check -->|no| bad["Exit 1:<br/>code and files disagree"]
check -->|yes| warn["Ask each widget's checks hook<br/>print warnings"]
- The version of the database is SQLite's
user_version. There are no down-migrations and no record of which file did what beyond that number. - Each file runs in one transaction together with the version bump. SQLite's schema changes are transactional, so a failed migration leaves the database as it was.
SCHEMA_VERSIONinapp/bootstrap.phpis the number the code expects. It must equal the highest migration.- Web requests never migrate. On the first connection of a request,
db_frames()comparesuser_versionwithSCHEMA_VERSION. A mismatch throwsSchemaMismatch, and the visitor sees503withRetry-After: 30and{"error": "maintenance", "code": "maintenance"}. After a deploy that adds a migration, the site answers503until you runphp app/bin/migrate.php. Frames keep their current page and retry. - The command-line tools (
user.php,kiosk-release.php) refuse to run on a mismatched database and tell you to runmigrate.phpfirst. migrate.phpruns withumask(0027)so new files are not world-readable, and needs thevardirectory to exist.
Add a migration
- Create
NNN_short_name.sqlwith the next number. Never edit a migration that has been applied anywhere. - Write SQL only. A migration cannot call PHP.
- Raise
SCHEMA_VERSIONinapp/bootstrap.phptoNNN. - Update
tests/migrate_test.php(it checks the version and what each migration does).fresh_db()in the test runner applies every file in order, so every test sees the new schema. - If existing rows need a value, backfill in the same file, as migrations 005, 007 and 011 did.
- Run the tests and PHPStan.
A widget that needs its own table must add a core migration today; widgets cannot ship migrations of their own.
Conventions
| Rule | Why |
|---|---|
Every table is STRICT. Column types are INTEGER, TEXT, REAL or BLOB |
SQLite then rejects a value of the wrong type instead of storing it. A BLOB column rejects TEXT, so bind images with PDO::PARAM_LOB |
Timestamps are INTEGER Unix seconds, UTC |
One format; no time-zone ambiguity. Times that are meant in the frame's own zone (pick_time, bedtime, wake_time) are TEXT HH:MM |
Booleans are INTEGER 0 or 1 with a CHECK |
|
Hashes and tokens are lowercase hex TEXT with CHECK (length(x) = 64 AND x NOT GLOB '*[^0-9a-f]*') |
The database refuses malformed values even if the application has a bug |
CHECK constraints repeat the application's rules (lengths, allowed values) |
Defense in depth |
Settings that vary by widget are JSON in a TEXT column |
frame_widgets.config, validated by the widget on save |
INTEGER PRIMARY KEY for most tables |
A rowid may be reused after the newest row is deleted |
INTEGER PRIMARY KEY AUTOINCREMENT where an id must never be reused |
kiosk_releases, photo_albums, photo_drop_links, photos. Rate limits and history are keyed by these ids; a reused id would inherit another record's limits |
Foreign keys say what happens on delete: CASCADE for owned rows, SET NULL for a record that outlives its source, RESTRICT where deleting would break a reference |
kiosk_releases cannot be deleted while a frame targets it |
WITHOUT ROWID for small tables with a composite key |
|
| Secrets are stored as hashes where the server never needs the original | Frame keys, link tokens, admin passwords, PINs |
Tables, by migration
001 init (the original schema)
| Table | Holds |
|---|---|
frames |
One row per frame: id (32 hex), key_hash, status (unclaimed, active, revoked), pairing code and expiry, name, display_kind and display_url, layout, tz, config_version, enrollment and last-seen details, and the last telemetry (app_version, local_ip, mac, ssid, rssi, reported_at). Indexes: a unique index on pairing_code (unclaimed rows only) and two for expiry and per-address limits |
frame_widgets |
(frame_id, widget) primary key, enabled, position, config (JSON). ON DELETE CASCADE from frames |
locations |
Library places: name (unique), lat, lon, optional stations |
calendars |
Library calendars: label, url (unique, a secret), color, created_at |
settings |
Key/value pairs. Reserved; the code does not use it now |
rate_events |
(bucket, at) rows that every rate limit counts. Index on (bucket, at) |
admins |
Dropped by 002 |
002 to 008 (frames grow)
| Migration | Change |
|---|---|
002 drop_admins |
Drops the admins table from 001. The built-in login arrived later, as admin_users in 012 |
003 idle_home |
frames.idle_home_seconds (default 120) |
004 uptime_ipkey |
frames.uptime; frames.enrolled_ip_key (the address, or the IPv6 /64) with an index, for enrollment limits |
005 frame_pin |
frames.pin_hash (JSON {alg, iter, salt, hash}). Moves the old global PIN into active frames and deletes it from settings |
006 ha_dashboards |
frames.ha_dashboards (JSON list of {path, title}) and ha_dashboards_at |
007 photo_set_bedtime |
frames.pick_time, bedtime, wake_time (with HH:MM checks; existing active frames get pick times two minutes apart from 05:00). Tables frame_photo_sets (the stored daily set: album_key, picked_for, picked_at, ids) and frame_photo_shown (photos already shown in this pass through an album) |
008 screenshots_photoset |
frames.screenshot_request_id (32 hex or null) and screenshot_requested_at; table frame_screenshots (the latest capture per frame, jpeg as a BLOB); frame_photo_sets.new_ids |
009 to 011 (cards and widget order)
| Migration | Change |
|---|---|
009 notes |
The cards feature: frame_notes (per-frame settings: keep_days 1 to 30, recipient_label, categories JSON or null for all), note_senders (a person's link: token_hash unique, revoked_at, last_used_at, unique (frame_id, name)), note_designs (covers) and note_stamps (file name is the hash plus extension, with a CHECK on its characters), note_cards (message 1 to 400 characters; exactly one of signature JSON or signature_text; opened_at; unique (sender_id, client_id) so a repeated send is the same card) |
010 notes_widget |
Makes cards a normal widget: copies each frame_notes.enabled into a frame_widgets row (position 7). frame_notes.enabled stays but is no longer read |
011 widget_enabled_at |
frame_widgets.enabled_at (when the widget was switched on, null while off). Existing enabled rows get 1735689600 + position so they keep their order. The admin lists widgets in this order |
012 admin_users (the built-in login)
admin_users: username (unique; GLOB '[a-z0-9]*', 1 to 64 characters), password_hash, created_at, password_changed_at, last_login_at, session_epoch (16 characters; changes to sign the user out everywhere), totp_secret (base32, null when two-step sign-in is off) and totp_last_step (the replay guard). Users are different from card senders. See Security.
013 kiosk_releases (self-update)
| Table or column | Holds |
|---|---|
kiosk_releases |
version_code (unique, 1 to 2,100,000,000), version_name (up to 64), sha256 (unique), size, signature (the detached release signature, base64), notes (up to 500), uploaded_at, uploaded_by. AUTOINCREMENT |
kiosk_downloads |
(frame_id, release_id) primary key, at. A frame that fetched a release in full. With a later report of that version it proves the release |
frames.kiosk_target_id |
The release this frame should run (foreign key to kiosk_releases, ON DELETE RESTRICT), and kiosk_target_at |
frames.kiosk_version_code |
The version the kiosk reported |
frames.kiosk_watchdog |
1 or 0: whether it reported a working rollback watchdog |
frames.kiosk_update_failure |
JSON {sha256, reason} of a held failure |
frames.kiosk_attempt |
Rises when an admin clears a failure, so the kiosk may retry |
014 photo_library (local photos)
| Table | Holds |
|---|---|
photo_albums |
name (unique, 1 to 60), created_at. AUTOINCREMENT |
photo_drop_links |
album_id (cascade), name (unique within the album, 1 to 40), token_hash (unique, 64 hex), created_at, expires_at, revoked_at, last_used_at. AUTOINCREMENT |
photos |
album_id (cascade), src_sha256 (hash of the uploaded bytes; unique with the album, for duplicate detection), file (hash of the stored JPEG; unique with the album), width, height, bytes, thumb_bytes, added_at, status (pending or approved), approved_at, drop_link_id (SET NULL), added_by. A CHECK ties status = 'approved' to approved_at being set. Indexes on album and time, album and status, file, and drop_link_id. AUTOINCREMENT |
Stored photos live in var/photos/files/, named by file. Several albums may share one file; it is deleted only when no row refers to it.
cache.db (created by the fetcher)
| Table | Holds |
|---|---|
cache |
k (key), v (JSON), fetched_at. Keys look like wx:1:obs, wx:1:hourly, cal:3, ph:0123456789abcdef |
fetch_state |
k, last_ok, last_err (up to 200 characters), fail_count, next_at (the earliest next try). Drives back-off |
photo_seen |
(album_key, file_id) primary key, added_at. When the fetcher first saw each Nextcloud photo. Rows are never deleted, so a bad listing cannot make an album look new |
How the tables relate
erDiagram
frames ||--o{ frame_widgets : "has"
frames ||--o| frame_screenshots : "latest capture"
frames ||--o| frame_photo_sets : "today's set"
frames ||--o{ frame_photo_shown : "shown so far"
frames ||--o| frame_notes : "card settings"
frames ||--o{ note_senders : "card links"
frames ||--o{ note_cards : "receives"
frames ||--o{ kiosk_downloads : "fetched"
frames }o--o| kiosk_releases : "target"
kiosk_releases ||--o{ kiosk_downloads : "fetched by"
note_senders ||--o{ note_cards : "sent"
note_designs ||--o{ note_cards : "cover"
note_stamps ||--o{ note_cards : "stamp"
photo_albums ||--o{ photos : "contains"
photo_albums ||--o{ photo_drop_links : "has"
photo_drop_links ||--o{ photos : "brought"
locations }o--o{ frame_widgets : "referenced in config"
calendars }o--o{ frame_widgets : "referenced in config"
admin_users
rate_events
Two relationships are not foreign keys. A widget's config JSON names a location or calendar by id, and the application checks that on save and before it deletes a Library record (409 in_use). A frame's local-album setting names an album by id the same way.
Rate-limit buckets
rate_events is one table for every limit. A bucket is a name; the limit counts rows with that name inside a window. rate_purge() deletes rows older than a day, and several request paths call it.
| Bucket | Counts |
|---|---|
enroll:<address key> |
Enrollments from an address |
pairing_fail |
Wrong pairing codes (all addresses together) |
login_fail_ip:<address key>, login_fail_user:<16 hex of a hash of the name> |
Admin sign-in attempts, refunded on success |
kiosk_dl:<frame id> |
Kiosk release downloads |
drop_auth_fail:<address key>, drop_req:<link id>, drop_up:<link id>, drop_up_ip:<address key>, drop_decode_fail:<link id> |
Drop link guesses, requests, uploads and rejected files |
note_auth_fail:<address key>, note_req:<sender id>, note_send:<sender id>, note_send_ip:<address key> |
Card link guesses, requests and sends |
The address key is the IPv4 address, or the IPv6 /64 as <16 hex>::/64.
Reading the data by hand
Open the file read-only, and do it as a user who may read it (the web user):
sudo -u www-data sqlite3 -readonly /srv/frame/var/frames.db \
"SELECT id, name, status, kiosk_version_code FROM frames;"
Do not write to frames.db while the site is up. If you must, stop the web server first, or use the admin, which holds the right locks. Never copy the live file with cp and expect a consistent copy; use sqlite3 … ".backup '<file>'".
Related
- Developer start: run the tests and a development copy
- API reference: the endpoints that read and write these tables
- Writing a widget:
frame_widgetsand the settings JSON - Install the frame server: create the database and set its permissions