Skip to content

feat(schema): CHECK constraints on the posts/comments flags - #31

Merged
mburns merged 1 commit into
mainfrom
feat/posts-check-constraints
Aug 24, 2026
Merged

feat(schema): CHECK constraints on the posts/comments flags#31
mburns merged 1 commit into
mainfrom
feat/posts-check-constraints

Conversation

@mburns

@mburns mburns commented Aug 24, 2026

Copy link
Copy Markdown
Member

Second of the two follow-ups. #26 constrained the leaf tables and deliberately
skipped these two; this is the awkward case it deferred.

What's constrained

Boolean flags that were only ever enforced in Lua:

  • postslocked, edited, is_self, over_18, deleted, stickied,
    comments_locked, approved, is_question
  • commentsedited, deleted, is_submitter, stickied, approved

The rebuild is the interesting part

posts is referenced by six tables and itself, carries the FTS5 sync triggers,
and both tables feed the v_daily_activity view. The recipe used for the leaf
tables is wrong here in three separate ways:

1. A referenced table must not be renamed aside. With foreign keys enabled,
ALTER TABLE ... RENAME rewrites the REFERENCES clauses of child tables to
follow the rename — so moving posts out of the way would have left comments
pointing at posts_old. That's a silent corruption: the schema still looks
plausible.

rebuild_referenced_table uses SQLite's documented order — build under a
temporary name, copy, drop the original, rename into place — so children keep
naming the table they always named. A spec asserts
PRAGMA foreign_key_list(comments) still says posts, and says neither
posts_old nor posts_rebuild.

2. Foreign keys have to be off for the drop, via a PRAGMA that only takes
effect outside a transaction. Lapis runs migrations without one unless asked
(transaction = "global" | "individual"), so this works — but it's a dependency
on that default, and the docs now say so. PRAGMA foreign_key_check runs
afterwards and the migration asserts on any orphan.

3. Triggers and views 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. The three FTS triggers and v_daily_activity come down first
and go back after.

The FTS index itself is never touched, so its contents still match the copied
rows. Both halves of that are tested: an existing post is still findable by
search after a rebuild, and a post created afterwards is indexed by the
reattached AFTER INSERT trigger — the second would fail silently if I'd
forgotten to put the triggers back.

Data handling

Rows are copied as-is, not normalized. Every writer sets these from a Lua
boolean, so a violation would mean data this schema should never have held —
same policy as #26's text enums, where failing loudly beats quietly rewriting
someone's content.

Verification

posts_constraints_spec (8 cases): both tables reject a non-boolean flag, every
value the app writes is still accepted, child foreign keys still resolve, a
re-run of [116] against real rows preserves posts/comments/votes and the view,
FTS survives and stays live, all 15 indexes are back, and
PRAGMA foreign_key_check is clean. Full suite 421 passing, coverage 86.2%,
stylua and luacheck clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_0128hUpuk1spKzk4UHburdki

Migration [114] constrained the leaf tables and skipped these two as the awkward
case. [116] finishes it: the boolean flags on posts (locked, edited, is_self,
over_18, deleted, stickied, comments_locked, approved, is_question) and on
comments (edited, deleted, is_submitter, stickied, approved).

The interesting part is the rebuild, not the constraints. `posts` is referenced
by six tables and by itself, carries the FTS5 sync triggers, and both tables feed
the v_daily_activity view -- so the recipe used for the leaf tables is wrong
here in three separate ways:

- With foreign keys enabled, ALTER TABLE ... RENAME rewrites the REFERENCES
  clauses of child tables to follow the rename. Renaming `posts` out of the way
  would leave `comments` pointing at `posts_old`. rebuild_referenced_table uses
  SQLite's documented order instead -- build under a temporary name, copy, drop
  the original, rename into place -- so children keep naming the table they
  always named. A spec asserts PRAGMA foreign_key_list(comments) still says
  posts, and that it says neither posts_old nor posts_rebuild.
- Foreign keys must be off for the drop, via a PRAGMA that is only effective
  outside a transaction. Lapis runs migrations without one unless asked, so this
  works, but it is a dependency on that default and the docs now say so.
  PRAGMA foreign_key_check runs afterwards and the migration asserts on orphans.
- SQLite re-parses the whole schema during the rename, so a view still pointing
  at the dropped table fails that parse. The three FTS triggers and
  v_daily_activity are dropped up front and recreated after.

The FTS index itself is never touched, so its contents still match the copied
rows. Specs cover both halves of that: an existing post is still findable by
search after a rebuild, and a post created afterwards is indexed by the
reattached AFTER INSERT trigger.

Rows are copied as-is rather than normalized. Every writer sets these from a Lua
boolean, so a violation would mean data this schema should never have held --
same policy as [114]'s text enums.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128hUpuk1spKzk4UHburdki
@mburns
mburns merged commit 97e4638 into main Aug 24, 2026
16 checks passed
@mburns
mburns deleted the feat/posts-check-constraints branch August 24, 2026 08:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant