Contracts for 14 default categories and the category suggestion (UX-3, UX-8)

- contracts/schema/migrations/0002_categories_v2.sql: server migration 0002 rebuilds categories with the
  14-key default_key CHECK, inserts housing, insurance, children and fees with server_versions taken from
  the global sequence, sets schema_version 2. server-v1.sql unchanged.
- contracts/category-keywords.csv: the keyword table of the suggestion (148 entries, from docs/ux).
- contracts/test-vectors/suggest_category.json: 65 Kotlin-only cases of CS-4 to CS-6.
- contracts/examples/E-1-health: schema_version 2.
- tools/contracts-check.py: reference suggestCategory, keyword-table checks, 0002 load checks on a fresh
  and an in-use database, plain icon/color check, new icon and colour tokens.
- android/domain: suggestCategory (CS-4 to CS-6, code points, platform NFC and categories through
  UnicodeText), vector runner for suggest_category, eight vector files.
- server/crates/kvitt-domain/tests/vectors.rs: suggest_category is Kotlin-only.

The OpenAPI DefaultCategoryKey extension and the two new category examples are held back until the
server's wire types know the new keys (M12b).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 08:10:57 +02:00
parent 86e08a12cf
commit aa765bbb48
12 changed files with 8295 additions and 13 deletions
@@ -0,0 +1,64 @@
-- Kvitt server schema v2: four new default categories (housing, insurance, children, fees). Do not edit; add a new
-- migration and bump the schema version (SPEC §14.8). Applied on top of server-v1.sql (0001_init.sql).
--
-- The default_key CHECK of categories cannot be altered in place in SQLite, so the table is rebuilt: copy the rows
-- aside, drop the table, recreate it with the new CHECK and copy the rows back, ids and server_version unchanged.
-- The migration runs inside one transaction (sqlx wraps every migration in one) with foreign_keys = ON (SPEC §8.9).
-- defer_foreign_keys = ON lets expenses and recurring_templates point at categories while the table is briefly
-- absent; the deferred check at commit still fails the migration if any reference is left dangling. It resets
-- itself when the transaction ends. No other table, index or trigger refers to categories by anything but name.
PRAGMA defer_foreign_keys = ON;
CREATE TABLE categories_v1_copy AS SELECT * FROM categories;
DROP TABLE categories;
CREATE TABLE categories (
id TEXT PRIMARY KEY NOT NULL,
default_key TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN
('groceries', 'restaurants', 'home', 'utilities', 'transport',
'travel', 'entertainment', 'health', 'shopping', 'other',
'housing', 'insurance', 'children', 'fees')),
name TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40),
icon TEXT NOT NULL,
color TEXT NOT NULL,
sort_order INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT,
server_version INTEGER NOT NULL UNIQUE,
CHECK (name IS NOT NULL OR default_key IS NOT NULL)
) STRICT;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version
FROM categories_v1_copy;
DROP TABLE categories_v1_copy;
-- Each new row takes the next server_version from the global sequence, as every server write does (SPEC §14.1), so
-- phones receive the rows on their next pull whatever their cursor.
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000011', 'housing', NULL, 'key', 'indigo', 25,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000012', 'insurance', NULL, 'shield', 'slate', 45,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000013', 'children', NULL, 'child_care', 'lime', 85,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000014', 'fees', NULL, 'account_balance', 'amber', 95,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE meta SET value = '2' WHERE key = 'schema_version';