Skip to content

fix: switch articles_fts to external-content FTS5 (closes #48)#50

Merged
thunpisit merged 1 commit intomainfrom
fix/fts5-contentless-bug-issue-48
May 3, 2026
Merged

fix: switch articles_fts to external-content FTS5 (closes #48)#50
thunpisit merged 1 commit intomainfrom
fix/fts5-contentless-bug-issue-48

Conversation

@thunpisit
Copy link
Copy Markdown
Contributor

Summary

Closes #48. Migrates `articles_fts` from a contentless to an external-content FTS5 virtual table. The contentless pattern from migration 0002 required `('delete', old.rowid, …all column values…)` tuples to match exactly — any drift broke UPDATE and DELETE on `article_localizations` with `SQLITE_ERROR`.

What changed

Migration 0011 (`drizzle/0011_fts5_external_content.sql`):

  1. Drop the three existing triggers + the old virtual table.
  2. Recreate as external-content with `content = 'article_localizations', content_rowid = 'rowid'`.
  3. `INSERT INTO articles_fts(articles_fts) VALUES('rebuild')` to populate from the linked table.
  4. New triggers use the simpler delete pattern that needs only rowid:
    ```sql
    INSERT INTO articles_fts(articles_fts, rowid) VALUES('delete', old.rowid);
    ```

Why external-content is the right fix

  • Drift becomes impossible. Delete only references rowid; nothing to match against.
  • Recovery in one statement. `articles_fts(articles_fts) VALUES('rebuild')` rebuilds the index from the source table without a DROP/CREATE.
  • Same query surface. `SELECT … FROM articles_fts WHERE articles_fts MATCH ?` keeps working — the application code doesn't change.

Live verification

Migration applied to khaopad-example-db. Reproduced the original bug pattern:

```sql
INSERT INTO article_localizations (...) VALUES (...); -- ok before fix
UPDATE article_localizations SET ... WHERE id='...'; -- threw SQLITE_ERROR before
DELETE FROM article_localizations WHERE id='...'; -- threw SQLITE_ERROR before
```

After the fix: all three succeed. `SELECT … FROM articles_fts WHERE articles_fts MATCH 'khao'` still returns ranked snippets.

Migration notes

  • Migration 0011 already applied to live khaopad-example-db.
  • Downstream installs need to apply 0011 via `pnpm run db:migrate:remote` (or equivalent).
  • The migration is idempotent within itself (uses `DROP IF EXISTS` / `CREATE IF NOT EXISTS`) so re-running is safe.
  • No data loss — the `rebuild` in step 3 reads from `article_localizations` (source of truth, untouched).

Test plan

  • Migration applies cleanly on live D1
  • INSERT / UPDATE / DELETE all succeed on `article_localizations` after fix
  • `articles_fts MATCH` still returns ranked + snippet results
  • `pnpm run build` succeeds
  • After deploy: edit an existing article via the CMS → save succeeds
  • After deploy: delete an article via the CMS → succeeds

🤖 Generated with Claude Code

The contentless FTS5 table from migration 0002 required the
delete-by-insert tuple to **exactly match** what's stored:

  INSERT INTO articles_fts(articles_fts, rowid, title, excerpt, body, locale, article_id)
  VALUES('delete', old.rowid, old.title, ...);

If anything drifted — different normalization, trailing newline,
prior failed REPLACE — the delete trigger threw SQLITE_ERROR. UPDATE
inherited the fault (delete-then-reinsert). End result: editors
couldn't update or delete an article_localizations row once any
drift had occurred.

Migration 0011 fixes it by switching to the **external-content**
FTS5 pattern:

  CREATE VIRTUAL TABLE articles_fts USING fts5(
    ...
    content = 'article_localizations',
    content_rowid = 'rowid',
    ...
  );

This means:

- Triggers reference rowid alone — no column values to match against,
  so drift can't break delete/update:
    INSERT INTO articles_fts(articles_fts, rowid) VALUES('delete', old.rowid);
- The source-of-truth values come from article_localizations itself
  via the `content =` link.
- `INSERT INTO articles_fts(articles_fts) VALUES('rebuild')` recovers
  from any inconsistency without a full DROP/CREATE cycle.

Migration 0011 already applied to live D1. Reproduced + verified the
fix end-to-end:
  INSERT → UPDATE → DELETE on article_localizations now all succeed
  where they previously threw SQLITE_ERROR; FTS search still returns
  ranked snippets correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@thunpisit thunpisit merged commit c6272df into main May 3, 2026
1 check passed
@thunpisit thunpisit deleted the fix/fts5-contentless-bug-issue-48 branch May 3, 2026 10:33
thunpisit added a commit to codustry/khaopad-example that referenced this pull request May 3, 2026
codustry#49 + PR codustry#50) (#15)

Cherry-picks both upstream fixes:
- PR codustry#49 (closes codustry#47) — 7 ESLint errors blocking CI on fresh install
- PR codustry#50 (closes codustry#48) — switch articles_fts from contentless to
  external-content FTS5; UPDATE/DELETE on article_localizations no
  longer throws SQLITE_ERROR

Migration 0011 already applied to live D1 (same database serves both
repos).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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.

FTS5 contentless-table trigger breaks UPDATE/DELETE on article_localizations

1 participant