A standing question on this project is "can a database/framework feature do
this instead of hand-rolled Lua?" This note records the verdicts so we don't
re-litigate them. Companion to docs/sqlean-plan.md (loadable extensions).
SQLite triggers are a good fit when a write must keep a derived structure consistent and we never want application code to forget to do it.
- FTS5 sync (adopted).
posts_ftsis kept current byposts_fts_ai/ad/autriggers onposts(migration[7]): every insert/update/delete mirrors into the full-text index. This is the canonical trigger use — the search index can't drift from the table. - Denormalized counters (declined, for now). We could maintain
forum.post_count/ per-day rollup tables withAFTER INSERT/DELETEtriggers. We don't, because: (a) our counts must respect soft deletes (deleted = 0) and the approval queue (approved = 1), so a trigger would need to fire on thoseUPDATEs too and carry non-trivial conditions; (b) the hot listing path already gets its vote/comment counts from indexed subqueries (covering indexes, migrations[5]/[6]); and (c) the seed migrations bulk- insert thousands of rows, where per-row triggers add avoidable cost. The stats reads are infrequent (admin/mod dashboards, a scraped/metrics), so an aggregate-on-read is cheaper overall than taxing every write.
Earlier work removed the v_hot_* / v_forum listing views (see
CHANGELOG.md): the main listing varies per request (sort, time window,
hidden/saved filters), so a fixed view couldn't capture it and the FK/partial
indexes serve that path directly.
That objection does not apply to static aggregations, so we now use one:
v_daily_activity(adopted, migration[110]). A view that buckets posts/comments/signups bydate(created_at). It backs the admin/mod activity graphs,/metrics, and/health. A view (vs. a trigger-maintained table) is right here because the data is purely read-side: the view is always consistent with zero write-path cost and no schema columns to backfill. Per-subreddit activity has no equivalent view — it needs asub_idbind parameter, which a view can't take — soStats.for_subaggregates directly.
Every foreign key into users was NO ACTION, so deleting an account meant
clearing each child table by hand first. Migration [115] splits the schema
along the line the product already draws:
- Personal rows cascade —
subscriptions,saved_posts,hidden_posts,notifications,password_resets,oauth_identities,roles,site_roles. These belong to one person and mean nothing without them. - Authored content stays
NO ACTION—posts,comments,votes,modlog. Their policy is reassignment to the anonymous account, not deletion; a cascade would silently take other people's threads down with the author. WithNO ACTION, deleting a user who still owns content fails, which is the correct outcome: something has to decide where it goes.
Two things to remember:
- A cascade only fires when
PRAGMA foreign_keys = ON, which is a per-connection setting (app.lua'stune_sqlite, and the spec helper). On a connection that missed it there is no cascade and no enforcement. That is whyUsers:delete_accountstill deletes the personal rows explicitly — the schema is a backstop, not the only copy of the policy. - Adding a referential action needs the same table rebuild a
CHECKdoes (see below);notifications,rolesandsite_rolesare rebuilt in both[114]and[115]. Both run once, so the repeat costs nothing at runtime.
Soft deletion in this schema means posts.deleted / comments.deleted for
content and forum.deleted_at for subreddits. Users are hard deleted, so the
never-read users.deleted_at column was dropped in [115].
Every table is STRICT (the strict option on schema.create_table), so a
column's declared type is enforced rather than advisory. That says nothing
about a column's values, which is what CHECK adds — and until migration
[114] we had none, so votes.upvote = 7 and roles.role = 'wizard' were both
storable. upvote was the one that mattered: it is read as
CASE WHEN upvote = 1 THEN 1 ELSE -1 END, so any stray value silently counted
as a downvote.
Constrained in [114]: votes.upvote, notifications.kind and seen,
roles.role, site_roles.role.
Adding one costs a table rebuild. SQLite has no
ALTER TABLE ... ADD CONSTRAINT, so the table is renamed, recreated with the
constraint, copied into, and dropped — the shape migration [105] already used.
Two consequences worth remembering next time:
- Indexes live on the table, so every index has to be recreated afterwards.
[114]rebuildsvotes, which carries five, including[112]'s partial uniques. The spec asserts they are all back. - Only leaf tables are cheap to rebuild. All four here are leaves — no foreign key points at them — so the rebuild cannot orphan a reference.
posts and comments followed in [116], and they are the case that shows why
the leaf-table recipe is not general:
- A referenced table must not be renamed out of the way. With foreign keys
enabled,
ALTER TABLE ... RENAMErewrites theREFERENCESclauses of child tables to follow the rename — so renamingpostsaside would leavecommentspointing atposts_old. SQLite's documented order avoids it: build the replacement under a temporary name, copy, drop the original, then rename the replacement into place, so the children keep naming the table they always named. That isrebuild_referenced_table, and a spec assertsPRAGMA foreign_key_list(comments)still sayspostsafterwards. - Foreign keys have to be off for the drop, via a
PRAGMAthat is only effective outside a transaction. Lapis runs migrations without one unless asked (transaction = "global" | "individual"), so this works — but it is a dependency on that default worth knowing about.PRAGMA foreign_key_checkruns afterwards and the migration asserts on any orphan. - Triggers and views attached to the table must be dropped and recreated.
SQLite re-parses the whole schema during the rename, and a view still pointing
at the dropped table fails that parse.
[116]drops the three FTS5 sync triggers andv_daily_activityup front and puts them back afterwards. The FTS index itself is never touched, so its contents still match the copied rows — specs cover both that old rows are still findable and that new writes are still indexed by the reattached triggers.
Prefer declaring CHECK on new tables, where it is free.
Listings used to fetch every matching post, rank them with utils/sort's
comparators in Lua, and slice 25 out of the resulting array (utils/paginate).
Page 1 of a subreddit cost the same as page 40, and the cost grew with the
table.
Posts:get_listing now takes sort, limit and offset and does the work in
SQL (ORDER_BY in models/posts.lua). Each expression is order-equivalent to
the comparator it replaces, not a re-invention:
| sort | SQL | note |
|---|---|---|
new |
created_at DESC |
|
best |
upvotes DESC |
|
top |
(upvotes - downvotes) DESC |
|
rising |
net score ÷ hours since posting | |
hot |
ABS(up - down) + strftime('%s', created_at)/45000.0 |
drops the outer log() — it is monotonic and its argument is always > 0, so the order is unchanged |
controversial |
POW(up + down, MIN/MAX), else 0 |
needs SQLite's math functions (3.35+) |
ORDER BY reads the upvotes/downvotes output aliases instead of
repeating the correlated subqueries that compute them. Every ordering ends
, a.id DESC: without a total order, equal-ranked rows can swap between
requests and LIMIT/OFFSET paging then repeats or skips them. (The
table.sort this replaced was itself unstable, so tie order was already
arbitrary — just invisibly so.)
What this does and does not buy. Checked with EXPLAIN QUERY PLAN:
newis satisfied byposts_sub_id_created_at_idxwith no temp b-tree, soLIMITgenuinely stops early.- The ranked sorts report
USE TEMP B-TREE FOR ORDER BY: the vote subqueries are still evaluated for every matching row, and SQLite sorts them internally.
So ranked listings did not become O(page) — the ranking work moved into SQLite (C, one bounded result set) instead of crossing into Lua as a full table of allocated rows. Only the page plus one lookahead row is returned. Making the ranked sorts index-driven would mean materializing scores (see the declined counter-triggers above), which is a different trade.
utils/paginate_db asks for per_page + 1 rows and treats the extra one as
"there is a next page", so a listing stays a single query with no companion
COUNT(*). utils/paginate (array slicing) remains for the profile's comment
list, which legitimately holds a full array.
/api speaks Reddit's after/before fullnames, and api_serialize.paginate
finds the cursor row by scanning the ordered rows it was handed — so it can only
reach a row the caller actually fetched. Rather than fetch everything, the
endpoints size their window to the request (S.window):
- no cursor (every first page, and the common case): the page plus one lookahead row.
- with a cursor:
S.MAX_DEPTH(1000) rows, which caps how deep a cursor can address.
new skips the cap entirely. Its key, (created_at, id), never moves once a
post is written, so the database can seek straight to the cursor row with a
row-value comparison and return only the page:
WHERE (a.created_at, a.id) < (SELECT created_at, id FROM posts WHERE id = ?)Comparing the whole key as a row value expresses "strictly past that row in this order" in one shot, tiebreaker included. A cursor id that no longer exists makes the subquery NULL, so the comparison is NULL and no rows come back — a stale cursor reads as "nothing after this", which is exactly the wanted answer. Walking backwards runs the comparison the other way in ascending order and flips the rows, so the caller always sees one order.
The ranked sorts keep the window-and-cap treatment, and that is a property
of the data rather than a shortcut: hot, controversial and rising compute
rank from live vote counts, so a row's position moves between requests and a
cursor into one is approximate however it is implemented. Keyset would relocate
the inaccuracy, not remove it. Search engines and Reddit cap deep paging for the
same reason.
Posts.KEYSET_SORTS is the list, and get_listing asserts when a cursor is
passed with a sort that is not on it — an unstable-key cursor would fail
silently and subtly otherwise.
A cursor that is not in the window (past the cap, or a row since deleted) now returns an empty page. It used to silently restart at the top, which left a client paging in a loop without ever learning it had reached the end.
A partial index (CREATE INDEX ... WHERE <predicate>) covers only the rows
matching its predicate. We use them two ways:
-
Hot-path filtering.
posts_sub_id_created_at_idxis builtWHERE deleted = 0 AND locked = 0, so the listing scan never walks rows it would immediately discard. -
Uniqueness over a nullable column (adopted, migration
[112]).voteswas created withUNIQUE(user_id, post_id, comment_id), which reads like "one vote per user per target" but is not: SQLite treats NULLs as distinct in a UNIQUE index, so for a post vote (comment_id IS NULL) the constraint never fires and one user could accumulate any number of votes on a post. The model's find-then-insert masked it single-threaded; production runs three nginx workers, where both can see "no existing row" and insert.The fix is two partial unique indexes, one per case, each over only the rows where the columns are non-NULL:
CREATE UNIQUE INDEX votes_user_post_uniq ON votes (user_id, post_id) WHERE comment_id IS NULL; CREATE UNIQUE INDEX votes_user_comment_uniq ON votes (user_id, comment_id) WHERE comment_id IS NOT NULL;
General rule for this schema: a UNIQUE constraint that includes a nullable column does not constrain the rows where that column is NULL. Reach for a partial unique index instead.
INSERT ... ON CONFLICT (...) DO UPDATE performs the whole decision in one
statement, so concurrent workers cannot both pass a "does it exist?" check and
both insert. Votes:cast/Votes:set use it (models/votes.lua); the conflict
target repeats the partial index's WHERE clause, which SQLite requires in
order to match a partial index.
This is the preferred shape for any "create it, or update the one that's there"
path in this codebase. Several remain on find-then-write (Roles:assign,
Subscriptions:toggle, saved/hidden toggles); they are lower-stakes than votes
because a duplicate there is idempotent rather than score-changing, but the same
treatment applies when they are next touched.
SQLite has no stored procedures or server-side functions in the SQL/PSM
sense; logic lives in the application (or in C via loadable extensions — see the
sqlean bundle). Lapis's nearest equivalent is the model layer: methods like
Votes:set, Posts:get_listing, and the Stats helper are where reusable
query logic belongs. That's the convention we follow instead.
db.select/db.querywith bound params for all dynamic SQL (never string-concatenate user input).- Models + relations (
Model:extend,belongs_to/has_many) for the CRUD surface;constraintsfor validation on create/update. respond_tofor verb dispatch,@csrffor the global form guard, etlua layouts/partials, and JSON responses (return { json = ... }) for the API.- Migrations as the single ordered source of schema truth (including the triggers and view above).