SWIFTPOINT//HUB
Support

Data model — the SQLite schema

This file documents every table in the hub's SQLite database: the columns, what each one is for, the indexes and the relations between them. It also covers how

This file documents every table in the hub's SQLite database: the columns, what each one is for, the indexes and the relations between them. It also covers how data moves through the system, what is pruned and what is kept, and why SQLite is the right choice here.

The database

One SQLite file, opened with better-sqlite3 in lib/db.js.

const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'swiftpoint-hub.db');
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
Setting Value Why
DB_PATH env, default ./data/swiftpoint-hub.db One variable relocates the whole database.
parent directory mkdirSync(…, { recursive: true }) on load A fresh clone does not need a data/ directory to exist first.
journal_mode WAL Readers do not block the writer, which matters because a page render is a read and an upload is a write.
foreign_keys ON SQLite defaults this off, per connection. Without it every ON DELETE clause in this file would be a comment.

Every migration is idempotent. A table is created with CREATE TABLE IF NOT EXISTS; a column added later is guarded by PRAGMA table_info(<table>) plus a conditional ALTER TABLE:

const userCols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name);
if (!userCols.includes('bio')) {
  db.exec("ALTER TABLE users ADD COLUMN bio TEXT NOT NULL DEFAULT ''");
}

The same shape is used for profiles.updated_at, profiles.game, the four profile counters, comments.kind, the four updates fields and the two notifications pointers. Booting an old database runs every one of these and changes nothing.

Backing up the site is copying that one file. With WAL, copy the -wal and -shm files too, or take the copy after a clean shutdown. There is no second service to snapshot, no dump command and no credential to rotate.

There are seventeen tables. Sixteen are created by lib/db.js; sessions is created by lib/session-store.js so that module is self-contained.

users

One row per Discord account that has ever signed in. Created by the OAuth callback; see Auth and roles.

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT internal id, referenced everywhere The site's own identifier for a person
discord_id TEXT UNIQUE nullable The Discord snowflake; the only column that is never published
username TEXT NOT NULL global_name or username, else Discord user Display name, refreshed on every sign-in
avatar_url TEXT nullable https://cdn.discordapp.com/avatars/<id>/<hash>.png?size=64
created_at TEXT NOT NULL datetime('now') The day they joined
bio TEXT NOT NULL '' — added by migration The free text on /users/<id>

Notes. discord_id is UNIQUE, which is what makes INSERT … ON CONFLICT(discord_id) the upsert. It is nullable so the table can exist before Discord login does — the comment in lib/db.js says exactly that: "users is here from day one so Discord login can be bolted on later without a schema migration: profiles.user_id just starts getting filled in". bio was added later by the PRAGMA table_info migration; users has no explicit index beyond the UNIQUE on discord_id. A user is never deleted directly: every reference to it is ON DELETE SET NULL or ON DELETE CASCADE, so the rows either lose the pointer or go with the account.

profiles

An uploaded .spcf. The central table.

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT The profile id in /profiles/<id>
title TEXT NOT NULL cleaned, max 80 Display name
author_name TEXT NOT NULL cleaned, max 60, fallback Anonym Who wrote it, as typed
description TEXT nullable, max 400 Free text
source_filename TEXT the original upload name, max 255 Provenance
format_header TEXT first line of the file, trimmed Half of the byte-exact download
version INTEGER nullable version from the document
tags TEXT NOT NULL '', comma-separated group names Filter chips and search
total_buttons INTEGER NOT NULL 0 Rollup: totalButtons
min_dpi INTEGER nullable Rollup
max_dpi INTEGER nullable Rollup
raw_json TEXT NOT NULL everything after the first newline The original document, byte-exact
parsed_summary TEXT NOT NULL JSON.stringify(parsed.groups) The derived display structure
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL Uploader
uploaded_by_admin INTEGER NOT NULL 0 Distinguishes a staff upload
created_at TEXT NOT NULL datetime('now')
updated_at TEXT nullable — added by migration Set on edit and on replace
game TEXT nullable — added by migration Canonicalised game name
views INTEGER NOT NULL 0 — added by migration Page-view counter
downloads INTEGER NOT NULL 0 — added by migration Download counter
upvotes INTEGER NOT NULL 0 — added by migration Recounted from votes
downvotes INTEGER NOT NULL 0 — added by migration Recounted from votes

Notes. The two JSON columns are the point of the table. raw_json is the original parsed document, kept so a download can be byte-identical to the upload; parsed_summary is this project's own derived structure — the groups, bindings and rollups the UI renders — kept so a profile page does not re-parse a document on every request. Because both are kept, the two can never disagree about what was uploaded, and the download is always the original rather than a reconstruction of the derived form.

tags is a comma-separated string of group names, derived on upload (withGameTag()), not a user-typed list. Tag filtering therefore has to match with ',' || tags || ',' LIKE '%,x,%' so that FPS cannot match FPSMAX. The game name is one of the tags, deduplicated by the same comparison key the taxonomy uses.

Indexes: idx_profiles_created_at(created_at) for the default newest-first gallery, and idx_profiles_tags(tags) for the tag filter. A profile delete cascades to its comments and votes.

comments

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE The profile it hangs off
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE The author
body TEXT NOT NULL cleaned, max 1000 The text
kind TEXT NOT NULL 'comment' — added by migration 'comment' or 'patch'
created_at TEXT NOT NULL datetime('now')

Notes. kind arrived after the table did, guarded by PRAGMA table_info(comments); the default is 'comment', so existing rows needed no backfill. 'patch' marks an author patch note, which only the profile's uploader may post (Only the profile author can post patch notes.) and which the API reports separately as isAuthor. Index: idx_comments_profile(profile_id, created_at), which is exactly the list query's ordering.

notifications

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE Recipient
type TEXT NOT NULL new_user, new_profile, profile_updated, new_comment, new_vote, report, new_ticket, forum_reply, ticket_reply, ticket_status, new_update What happened
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL Who did it
actor_name TEXT nullable Their name at the time, kept for the text
profile_id INTEGER REFERENCES profiles(id) ON DELETE CASCADE Where it happened, profile surfaces
comment_id INTEGER REFERENCES comments(id) ON DELETE CASCADE The comment, where there is one
text TEXT NOT NULL The line the bell shows
read INTEGER NOT NULL 0 Read flag
created_at TEXT NOT NULL datetime('now')
thread_id INTEGER REFERENCES forum_threads(id) ON DELETE CASCADE — added by migration Forum surfaces
post_id INTEGER REFERENCES forum_posts(id) ON DELETE CASCADE — added by migration Forum surfaces

Notes. thread_id and post_id were added when the forum arrived, and the source comment explains why they are not in the CREATE TABLE above:

SQLite resolves a REFERENCES clause against a table that already exists, and the notification table is defined before the forum is.

Indexes: idx_notifications_user(user_id, read) for the unread badge, and idx_notifications_created(user_id, created_at) for the list, which is capped at 50 rows.

reports

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT
target_type TEXT NOT NULL documented as profile or comment Which kind of thing was reported
target_id INTEGER NOT NULL The reported row's id
reporter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL Who reported it
reason TEXT NOT NULL cleaned, max 500 Why
status TEXT NOT NULL 'open' 'open', 'resolved', 'dismissed'
created_at TEXT NOT NULL datetime('now')

Notes. The column comment says 'profile' | 'comment', and that is what the schema was written for — but routes/reports.js sets TARGET_TYPES = new Set(['profile', 'comment', 'post']) and the thread page posts post. A reported post is stored with target_type = 'post' and resolved by joining forum_posts to forum_threads, because it has no profile to point at. The reported item is not a foreign key, deliberately: target_id means a different thing per target_type, and one polymorphic reference cannot be constrained by a single REFERENCES clause.

The duplicate rule is enforced in application code, not by a unique index: one open report per user per target, answered with 409 and You already reported this. A resolved report does not block a second one.

Indexes: idx_reports_status(status, created_at) for the queue and idx_reports_target(target_type, target_id) for "has this been reported".

pageviews

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT
visitor_hash TEXT NOT NULL HMAC-SHA256 of the client IP, never raw Counting unique visitors
path TEXT NOT NULL truncated to 255 Which page
created_at TEXT NOT NULL datetime('now') The window queries

Notes. One insert per page view and nothing else — no events, no click tracking, no profile of a person. The hash key is SESSION_SECRET (or the development fallback swiftpoint-hub-analytics-salt), so the same IP hashes to the same value within this install and to nothing usable anywhere else. See SEO and analytics and Security and privacy.

Indexes: idx_pageviews_created(created_at) for the h1 / h24 / d7 / d30 windows and for the 30-day prune, and idx_pageviews_visitor(visitor_hash, created_at) for COUNT(DISTINCT visitor_hash) inside a window.

votes

Column Type Default / notes What it is for
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
value INTEGER NOT NULL 1 or -1 Up or down
created_at TEXT NOT NULL datetime('now')
— PRIMARY KEY (profile_id, user_id) composite One vote per user per profile

Notes. The composite primary key is the whole design: a vote is an upsert, so voting again replaces the value and there is no way for a user to hold two votes on one profile. value = 0 is accepted by the route and simply removes the vote.

The counters on profiles are recounted, never incremented:

UPDATE profiles SET
  upvotes   = (SELECT COUNT(*) FROM votes WHERE profile_id = ? AND value = 1),
  downvotes = (SELECT COUNT(*) FROM votes WHERE profile_id = ? AND value = -1)
WHERE id = ?

so the two can never drift from the rows that justify them. A vote on your own profile is refused with You can't vote on your own profile. Index: idx_votes_profile(profile_id) serves the recount and the per-profile total.

settings

Column Type Default / notes What it is for
key TEXT PRIMARY KEY e.g. max_profiles_per_user
value TEXT NOT NULL always a string
updated_at TEXT NOT NULL datetime('now')

Notes. The whole module is two functions, lib/settings.js: getSetting(key, fallback) and setSetting(key, value), the second an upsert. Everything is stored as text, so a caller coerces; the one setting that matters for limits is max_profiles_per_user, read as Math.max(1, Number(getSetting('max_profiles_per_user', MAX_PROFILES_PER_USER)) || MAX_PROFILES_PER_USER). The PRIMARY KEY on key is the index.

forum_categories

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT
slug TEXT NOT NULL UNIQUE The URL segment
name TEXT NOT NULL The heading
description TEXT NOT NULL '' Shown under the name
kind TEXT NOT NULL 'discussion' 'discussion' or 'support'
sort INTEGER NOT NULL 0 Display order
created_at TEXT NOT NULL datetime('now')

Notes. kind is what makes the ticket system possible without a second set of tables: a category marked 'support' turns every thread in it into a ticket, and a 'discussion' category carries NULL in the thread's status columns, so "is this a ticket" is answered by the category rather than by a flag somebody can set and forget. A category added later by a moderator can be either kind. slug is UNIQUE, which is what the seed's ON CONFLICT(slug) DO NOTHING needs.

forum_threads

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT
category_id INTEGER NOT NULL REFERENCES forum_categories(id) ON DELETE CASCADE
author_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL Opener
title TEXT NOT NULL cleaned, max 120
pinned INTEGER NOT NULL 0
locked INTEGER NOT NULL 0
views INTEGER NOT NULL 0 Counted for signed-in readers only
created_at TEXT NOT NULL datetime('now')
updated_at TEXT NOT NULL datetime('now')
last_post_at TEXT NOT NULL datetime('now') Denormalised, for the activity sort
last_post_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL Who to attribute the bump to
status TEXT NULL for a non-ticket 'open', 'in_progress', 'on_hold', 'closed'
status_note TEXT NOT NULL '' Why
status_changed_at TEXT nullable
status_changed_by INTEGER REFERENCES users(id) ON DELETE SET NULL Who moved it

Notes. status IS NULL is the single field that makes a ticket a ticket, and lib/forum.js refuses a status change on a discussion with That is a discussion, not a ticket — it has no status. The four status columns are denormalised from the trail for the list queries; forum_ticket_events is the record of how they got there. last_post_at and last_post_user_id are maintained on every reply so the activity sort does not have to aggregate the posts table.

Indexes: idx_forum_threads_category(category_id, pinned DESC, last_post_at DESC) for a category listing, idx_forum_threads_last(last_post_at DESC) for the activity feed, idx_forum_threads_status(status, last_post_at DESC) for the ticket queue.

forum_posts

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT
thread_id INTEGER NOT NULL REFERENCES forum_threads(id) ON DELETE CASCADE
author_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
body TEXT NOT NULL markdown, cleaned, max 8000
edited_at TEXT nullable Set by POST /posts/:id/edit
deleted INTEGER NOT NULL 0 Soft delete
created_at TEXT NOT NULL datetime('now')

Notes. deleted is a soft delete: the row is kept, body becomes (deleted) and the flag is set, so replies keep their order and the fact that something was there survives. Posts are never hard-deleted, which is why the forum's post counts are computed with deleted = 0. The thread's first post cannot be soft-deleted even by a moderator; the answer is to delete the thread, and the refusal says so.

Indexes: idx_forum_posts_thread(thread_id, created_at) for a thread page, idx_forum_posts_author(author_user_id, created_at DESC) for "your posts".

forum_ticket_events

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT
thread_id INTEGER NOT NULL REFERENCES forum_threads(id) ON DELETE CASCADE
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL Who moved it
from_status TEXT nullable NULL when a ticket starts as a ticket
to_status TEXT NOT NULL Where it went
note TEXT NOT NULL '' Why, cleaned
created_at TEXT NOT NULL datetime('now')

Notes. The audit trail: who moved a ticket, when, from what to what, and why. A support system without this is a status field people argue about in the thread. to_status is NOT NULL because "what is the ticket now" always has an answer, while from_status is nullable because the first entry has no previous state. Index: idx_forum_ticket_events_thread(thread_id, created_at) for the trail on the thread page.

forum_subscriptions

Column Type Default / notes What it is for
thread_id INTEGER NOT NULL REFERENCES forum_threads(id) ON DELETE CASCADE
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
created_at TEXT NOT NULL datetime('now')
— PRIMARY KEY (thread_id, user_id) composite One subscription per person per thread

Notes. "Tell me when this changes." The person who opened a ticket is in it from the start, because opening one subscribes the author; every reply also does INSERT OR IGNORE, so replying is subscribing. A moderator can add anybody else. There is no index beyond the composite primary key, because every query asks by (thread_id) and user_id — the watchers of one thread.

updates

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT
kind TEXT NOT NULL 'website' 'website' or 'mouse'
title TEXT NOT NULL
body TEXT NOT NULL '', markdown The full entry
author_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
pinned INTEGER NOT NULL 0
created_at TEXT NOT NULL datetime('now')
updated_at TEXT NOT NULL datetime('now')
version TEXT NOT NULL '' — added by migration The label, e.g. Firmware 2.1
summary TEXT NOT NULL '' — added by migration One line, written for the list
accent TEXT NOT NULL 'lime' — added by migration The colour of the whole entry
tags TEXT NOT NULL '[]' — added by migration JSON array string of tool tags

Notes. Two kinds because they are told to different people: a website update is irrelevant to somebody whose problem is the OLED, and vice versa. The four migration-era columns exist because an entry that is only a title and a wall of text cannot be scanned, and the whole point of the page is that somebody can read thirty of them in a minute and work out whether they have to care about this one. summary is what the front page shows, accent gives a mouse entry and a site entry a different colour, and tags is a JSON array string — hence TEXT NOT NULL DEFAULT '[]' rather than a real array. They were added as columns rather than as a second table: an update is one row with one body, and four nullable fields beat a key/value table anybody has to join to read a changelog.

Index: idx_updates_created(pinned DESC, created_at DESC), which is the whole list query.

staff

Column Type Default / notes What it is for
discord_id TEXT PRIMARY KEY The Discord snowflake, keyed directly
username TEXT NOT NULL '', truncated to 64 For the panel's list
role TEXT NOT NULL 'moderator' 'moderator' or 'admin'
added_at TEXT NOT NULL datetime('now')
added_by TEXT NOT NULL '' Who added them

Notes. The table keys on discord_id rather than on a users.id foreign key, because the point of it is to be able to add somebody who has never signed in. It is merged with the .env allowlists at read time by lib/admin.js, with the environment as the stronger claim. There is no index beyond the primary key: the whole table is read on every cache miss. See Auth and roles.

recordings

Column Type Default / notes What it is for
id INTEGER PK AUTOINCREMENT
slug TEXT NOT NULL UNIQUE The guide reference recordings/<slug>.rec.json
title TEXT NOT NULL
description TEXT NOT NULL ''
data TEXT NOT NULL the whole .rec.json document as text Step actions and per-step screenshots
step_count INTEGER NOT NULL 0 Denormalised for the list
published INTEGER NOT NULL 0 1 when written into docs/recordings/
owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
created_at TEXT NOT NULL datetime('now')
updated_at TEXT NOT NULL datetime('now')

Notes. data is the whole document rather than a set of columns, because a recording is a list of steps whose payloads are per-step screenshots as data URLs — a shape a relational schema would only obscure. step_count is denormalised so the admin list does not have to parse every document. Guides read recordings through a public path in routes/docs.js — the repository file first, this table as a fallback — never through /api/records, which is admin-only. Index: idx_recordings_created(created_at); slug is UNIQUE.

sessions

Created by lib/session-store.js, not by lib/db.js.

Column Type Default / notes What it is for
sid TEXT PRIMARY KEY the cookie id
data TEXT NOT NULL JSON.stringify(session) The whole session, including the cookie's own expires
expires_at INTEGER NOT NULL epoch milliseconds When the row stops being valid

Notes. The cookie's expires lives inside data, which is why touch rewrites both columns and why _expiryFor() prefers the cookie's own expiry over the store TTL. get() reports an expired row as missing and deletes it on the way out. Index: idx_sessions_expires(expires_at), which is the only one — every statement filters or orders on it. Full detail in Auth and roles.

The indexes

Index Columns The query it serves
idx_profiles_created_at profiles(created_at) the gallery's default newest-first order
idx_profiles_tags profiles(tags) the gallery's tag filter
idx_comments_profile comments(profile_id, created_at) a profile's comments, oldest first
idx_notifications_user notifications(user_id, read) the unread-count badge
idx_notifications_created notifications(user_id, created_at) the 50-row notification list
idx_reports_status reports(status, created_at) the report queue, open first then newest
idx_reports_target reports(target_type, target_id) whether something has been reported
idx_pageviews_created pageviews(created_at) the h1 / h24 / d7 / d30 windows and the 30-day prune
idx_pageviews_visitor pageviews(visitor_hash, created_at) COUNT(DISTINCT visitor_hash) within a window
idx_votes_profile votes(profile_id) the up/down recount on profiles
idx_forum_threads_category forum_threads(category_id, pinned DESC, last_post_at DESC) one category's thread list
idx_forum_threads_last forum_threads(last_post_at DESC) the forum's activity feed
idx_forum_threads_status forum_threads(status, last_post_at DESC) the moderator's ticket queue
idx_forum_posts_thread forum_posts(thread_id, created_at) a thread page window
idx_forum_posts_author forum_posts(author_user_id, created_at DESC) a user's own posts
idx_forum_ticket_events_thread forum_ticket_events(thread_id, created_at) the ticket trail
idx_updates_created updates(pinned DESC, created_at DESC) the updates list
idx_recordings_created recordings(created_at) the admin recordings list
idx_sessions_expires sessions(expires_at) the sweep and the live-session count

Indexes that exist because of a constraint rather than a CREATE INDEX: users.discord_id (UNIQUE), forum_categories.slug (UNIQUE), recordings.slug (UNIQUE), settings.key, staff.discord_id, votes(profile_id, user_id) and forum_subscriptions(thread_id, user_id) (both composite primary keys), and sessions.sid (primary key).

The seeded forum categories

DEFAULT_CATEGORIES in lib/db.js, inserted with ON CONFLICT(slug) DO NOTHING inside a transaction, so a fresh install has a usable forum on the first request and an install that already has categories keeps exactly the ones it has.

slug name kind sort description
general General discussion 10 The Z3 itself, this hub, and anything that does not fit anywhere else.
profiles Profiles & bindings discussion 20 Share a .spcf, ask about DPI steps, deep-click, tilt and OLED setups.
help Support support 30 Stuck on something? Open a ticket here and a moderator picks it up and keeps you posted.
bugs Bugs support 40 Something on the site or in the mouse behaves wrongly. Tickets here carry a status.
off-topic Off-topic discussion 50 Everything else.

The kind is the part that matters: the two support categories turn every thread in them into a ticket, and the three discussion categories never carry a status. The seed is idempotent, so re-running it after a moderator renamed a description does not put the old text back.

How data flows through the system

An upload. POST /api/profiles with a multipart file:

multer (memory storage, 2 MB cap) → cleanField on title / author / description / game
  → per-user cap check (429 for a non-admin over the limit)
  → parseSpcf(text): strip BOM, take the header line, test ^json([,\s]|$),
                     refuse a NUL byte (binary), JSON.parse, validateRoot()
  → raw_json       = text.slice(firstNewline + 1)      — everything after the first newline
  → format_header  = parsed.formatHeader
  → parsed_summary = JSON.stringify(parsed.groups)
  → rollups: total_buttons, min_dpi, max_dpi
  → tags = withGameTag(parsed.rollup.tags, game)       — comma-separated
  → INSERT INTO profiles, one row
  → notifyAdmins({ type: 'new_profile', … })

A download. GET /api/profiles/:id/download:

UPDATE profiles SET downloads = downloads + 1 WHERE id = ?
body = format_header + "\n" + raw_json
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="<title, [a-z0-9_-] only>.spcf"

The download is a concatenation of two stored columns, not a re-serialisation, which is what makes it byte-identical to the upload.

A vote. POST /api/profiles/:id/vote:

refuse a value outside {1, -1, 0}; refuse a vote on your own profile
INSERT INTO votes (profile_id, user_id, value) … ON CONFLICT DO UPDATE   -- an upsert
UPDATE profiles SET upvotes = COUNT(votes WHERE value = 1),
                     downvotes = COUNT(votes WHERE value = -1)          -- a recount, not +1
notifyAdmins({ type: 'new_vote' }) and notifyUser(the owner), unless the owner is an admin

A forum reply. POST /api/forum/threads/:id/posts, in one transaction:

INSERT INTO forum_posts (thread_id, author_user_id, body)
UPDATE forum_threads SET last_post_at = datetime('now'), last_post_user_id = ?, updated_at = datetime('now')
INSERT OR IGNORE INTO forum_subscriptions (thread_id, user_id)

then, after the transaction, notifyThreadAudience(): every watcher except the person who just wrote this, plus the thread's author if they are not already on that list and are not the actor. The author is removed from the watcher list rather than notified twice, because a support desk that sends two bells for one answer trains people to ignore the bell. A new thread in a support category additionally notifies the moderators (new_ticket), because a ticket nobody is told about is not a ticket.

A ticket status change. POST /api/forum/threads/:id/status, requireModerator, also in one transaction:

INSERT INTO forum_ticket_events (thread_id, actor_user_id, from_status, to_status, note, created_at)
UPDATE forum_threads SET status = ?, status_note = ?, status_changed_at = ?, status_changed_by = ?

The trail row goes in before the thread update and both are in the same transaction, so if the write of the trail fails the status does not move. Then the watchers and the author are notified with the new status and the note.

A page view. On a server-rendered page, outside any guard:

INSERT INTO pageviews (visitor_hash, path)
    visitor_hash = HMAC-SHA256(req.ip, SESSION_SECRET || 'swiftpoint-hub-analytics-salt')
    path         = String(req.path).slice(0, 255)
every 200th insert: DELETE FROM pageviews WHERE created_at < datetime('now', '-30 days')

Page views from an admin session are skipped on the static and tool pages, and on a profile page the admin's view is excluded from the views counter as well — that counter answers "how many readers has this profile had", and an operator checking their own work is not a reader. The gallery and the user page count everybody.

Retention

What When it goes Where
pageviews rows older than 30 days on every 200th insert lib/analytics.js
sessions rows past expires_at in the store's constructor, then every 100 writes lib/session-store.js
everything else — accounts, profiles, comments, votes, threads, posts, reports, updates, recordings, notifications, staff rows kept until somebody asks —

Both prunes ride along with writes rather than a timer, because a timer would keep the process alive for no reason. Both are approximate by design, and the session one is backstopped at read time: an expired session reads as missing even before the sweep reaches it.

Nothing else is deleted automatically. Deleting a profile cascades to its comments, votes and notifications; deleting a user cascades to their comments, votes, subscriptions and notifications, and nulls the authorship pointers on threads, posts and profiles, so nothing is left dangling and nothing is silently rewritten. The privacy page's retention claim is generated from FACTS.retention in lib/legal.js, which is taken from these two prune points, and test/legal-pages.test.js fails when the page and the code disagree — see Security and privacy.

Why SQLite here

One file. No separate database to install, run, back up, secure, or explain to whoever takes the site over. The backup is cp. That is most of the argument.

The workload fits. A page render is a handful of small indexed reads plus a page-view insert; a write is an upload, a post, a vote or a status change — a few hundred a day on a community site, and read-mostly in aggregate. better-sqlite3 is synchronous, which is finer here rather than a compromise: the statements are prepared once, the queries are indexed and sub-millisecond, and an async driver would add a promise per query and buy nothing. It is also why lib/db.js can prepare a statement and call .run() in the middle of an ordinary function, which is what makes lib/forum.js and lib/admin.js readable.

WAL keeps the reader and the writer out of each other's way, and foreign_keys = ON gives the relations real enforcement instead of documentation.

The honest constraint is that SQLite means a single process. An SSE notification event only reaches the connection held by the process that produced it, so a second worker behind a load balancer would serve pages perfectly and quietly break live notifications; lib/session-store.js documents the same limitation for sessions and solves it by putting them in the database rather than in RAM. That trade is accepted deliberately — one process, one file, one box, no orchestration — until the site outgrows it, at which point the migrations are the only work.


Next: The API reference · Auth and roles · Security and privacy


Back to the documentation index.