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

text
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_VERSION in app/bootstrap.php is the number the code expects. It must equal the highest migration.
  • Web requests never migrate. On the first connection of a request, db_frames() compares user_version with SCHEMA_VERSION. A mismatch throws SchemaMismatch, and the visitor sees 503 with Retry-After: 30 and {"error": "maintenance", "code": "maintenance"}. After a deploy that adds a migration, the site answers 503 until you run php 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 run migrate.php first.
  • migrate.php runs with umask(0027) so new files are not world-readable, and needs the var directory to exist.

Add a migration

  1. Create NNN_short_name.sql with the next number. Never edit a migration that has been applied anywhere.
  2. Write SQL only. A migration cannot call PHP.
  3. Raise SCHEMA_VERSION in app/bootstrap.php to NNN.
  4. 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.
  5. If existing rows need a value, backfill in the same file, as migrations 005, 007 and 011 did.
  6. 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):

sh
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>'".