Core: tag names fold every letter, as the server does (#5385)

SQLite's lower() folds ASCII only, so a device could hold "Café" and
"CAFÉ" as two tags while the server held one. derive::fold (to_lowercase)
is now the one comparison. It is registered as the deterministic SQL function
fold() on every connection (schema::migrate), and find_or_create, the rename
clash, the pull clash and the unique index all use it. derive's push_unique
used eq_ignore_ascii_case and now folds the same way.

v14 merges pairs a device already holds before rebuilding the index. The
older row survives, as in rename_label. Memberships move with via_tag kept,
affected notes go dirty, and the merged-away row is a pending delete. It is
written out rather than calling store::merge_labels so the migration doesn't
depend on store code that later versions may change.

rusqlite gains its "functions" feature (operator-approved, 2026-10-08).
grammar.json gains "#café and #CAFÉ" -> ["café"], which all three
implementations run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 17:01:42 -04:00
co-authored by Claude Opus 5.5
parent 921c0a47bd
commit 0ecff7eeb9
6 changed files with 210 additions and 10 deletions
+3 -1
View File
@@ -11,7 +11,9 @@ serde_json = { workspace = true }
log = { workspace = true }
# Local-first store (M10.4): bundled = compile SQLite in, so there's no system
# libsqlite dependency to vary across the AppImage / native / Windows / Android builds.
rusqlite = { version = "0.32", features = ["bundled"] }
# functions = the `fold` SQL function tag names are compared and indexed by (#5385):
# SQLite's own lower() folds ASCII only, and the server folds every letter.
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
uuid = { version = "1", features = ["v4"] }
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
chrono = { version = "0.4", default-features = false, features = ["clock"] }