M2b: Android skeleton, :core:model contract, :domain passing all vectors

Gradle project in android/ with every module of SPEC §6.2 (build-logic convention plugins, version catalog,
architecture check in check), :core:model types and repository interfaces of §14.2.4 and §14.5, :domain
implementing §14.5.2 and §14.5.4 (125 vector cases), scenario-driven fakes with the empty scenario, Room 3
entities and KvittDatabase v1 with exported schema, :app shell with Hilt, en/sv strings with MissingTranslation
as a lint error, Roborazzi goldens of the empty screen in light and dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:23:43 +02:00
parent 76ee957f41
commit c277ee37c2
93 changed files with 4915 additions and 1 deletions
+18
View File
@@ -0,0 +1,18 @@
// :data — Room database, Ktor client, sync engine and the real repositories (owned by M5). M2b ships the Room
// entities of SPEC §14.2 and the database class with its exported schema (version 1), no DAOs.
plugins {
id("kvitt.android.library")
alias(libs.plugins.ksp)
alias(libs.plugins.room3)
}
room3 {
schemaDirectory("$projectDir/schemas")
}
dependencies {
implementation(project(":core:model"))
implementation(project(":domain"))
implementation(libs.androidx.room3.runtime)
ksp(libs.androidx.room3.compiler)
}
@@ -0,0 +1,986 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "294140ea943f5cc1f2c01e4725cac69d",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `role` TEXT NOT NULL, `display_name` TEXT NOT NULL, `picture_version` INTEGER, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `server_version` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pictureVersion",
"columnName": "picture_version",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverVersion",
"columnName": "server_version",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUpdatedAt",
"columnName": "local_updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_by_user_id` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `archived_at` TEXT, `deleted_at` TEXT, `server_version` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdByUserId",
"columnName": "created_by_user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "archivedAt",
"columnName": "archived_at",
"affinity": "TEXT"
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "TEXT"
},
{
"fieldPath": "serverVersion",
"columnName": "server_version",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUpdatedAt",
"columnName": "local_updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_accounts_created_at",
"unique": false,
"columnNames": [
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_created_at` ON `${TABLE_NAME}` (`created_at`)"
}
]
},
{
"tableName": "account_default_shares",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_bp` INTEGER NOT NULL, PRIMARY KEY(`account_id`, `user_id`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shareBp",
"columnName": "share_bp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"account_id",
"user_id"
]
}
},
{
"tableName": "categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `default_key` TEXT, `name` TEXT, `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, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultKey",
"columnName": "default_key",
"affinity": "TEXT"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "archivedAt",
"columnName": "archived_at",
"affinity": "TEXT"
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "TEXT"
},
{
"fieldPath": "serverVersion",
"columnName": "server_version",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUpdatedAt",
"columnName": "local_updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "recurring_templates",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `account_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, `description` TEXT NOT NULL, `notes` TEXT NOT NULL, `payer_user_id` TEXT NOT NULL, `split_mode` TEXT NOT NULL, `day_of_month` INTEGER NOT NULL, `start_period` TEXT NOT NULL, `end_period` TEXT, `paused` INTEGER NOT NULL, `last_generated_period` TEXT, `created_by_user_id` TEXT NOT NULL, `updated_by_user_id` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `deleted_at` TEXT, `server_version` INTEGER NOT NULL, `next_occurrence_date` TEXT, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "amountOre",
"columnName": "amount_ore",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payerUserId",
"columnName": "payer_user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splitMode",
"columnName": "split_mode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dayOfMonth",
"columnName": "day_of_month",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startPeriod",
"columnName": "start_period",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endPeriod",
"columnName": "end_period",
"affinity": "TEXT"
},
{
"fieldPath": "paused",
"columnName": "paused",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastGeneratedPeriod",
"columnName": "last_generated_period",
"affinity": "TEXT"
},
{
"fieldPath": "createdByUserId",
"columnName": "created_by_user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedByUserId",
"columnName": "updated_by_user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "TEXT"
},
{
"fieldPath": "serverVersion",
"columnName": "server_version",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "nextOccurrenceDate",
"columnName": "next_occurrence_date",
"affinity": "TEXT"
},
{
"fieldPath": "localUpdatedAt",
"columnName": "local_updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_recurring_templates_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_recurring_templates_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
]
},
{
"tableName": "recurring_template_splits",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`template_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_bp` INTEGER, `share_ore` INTEGER, PRIMARY KEY(`template_id`, `user_id`))",
"fields": [
{
"fieldPath": "templateId",
"columnName": "template_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shareBp",
"columnName": "share_bp",
"affinity": "INTEGER"
},
{
"fieldPath": "shareOre",
"columnName": "share_ore",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"template_id",
"user_id"
]
}
},
{
"tableName": "expenses",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `account_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, `description` TEXT NOT NULL, `notes` TEXT NOT NULL, `expense_date` TEXT NOT NULL, `payer_user_id` TEXT NOT NULL, `split_mode` TEXT NOT NULL, `recurring_template_id` TEXT, `recurring_period` TEXT, `created_by_user_id` TEXT, `updated_by_user_id` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `deleted_at` TEXT, `server_version` INTEGER, `pending` INTEGER NOT NULL, `pending_mutation_id` TEXT, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "amountOre",
"columnName": "amount_ore",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "expenseDate",
"columnName": "expense_date",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payerUserId",
"columnName": "payer_user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splitMode",
"columnName": "split_mode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recurringTemplateId",
"columnName": "recurring_template_id",
"affinity": "TEXT"
},
{
"fieldPath": "recurringPeriod",
"columnName": "recurring_period",
"affinity": "TEXT"
},
{
"fieldPath": "createdByUserId",
"columnName": "created_by_user_id",
"affinity": "TEXT"
},
{
"fieldPath": "updatedByUserId",
"columnName": "updated_by_user_id",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "TEXT"
},
{
"fieldPath": "serverVersion",
"columnName": "server_version",
"affinity": "INTEGER"
},
{
"fieldPath": "pending",
"columnName": "pending",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pendingMutationId",
"columnName": "pending_mutation_id",
"affinity": "TEXT"
},
{
"fieldPath": "localUpdatedAt",
"columnName": "local_updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_expenses_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_expenses_account_id` ON `${TABLE_NAME}` (`account_id`)"
},
{
"name": "index_expenses_expense_date",
"unique": false,
"columnNames": [
"expense_date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_expenses_expense_date` ON `${TABLE_NAME}` (`expense_date`)"
},
{
"name": "index_expenses_pending",
"unique": false,
"columnNames": [
"pending"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_expenses_pending` ON `${TABLE_NAME}` (`pending`)"
}
]
},
{
"tableName": "expense_splits",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`expense_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_ore` INTEGER NOT NULL, `share_bp` INTEGER, PRIMARY KEY(`expense_id`, `user_id`))",
"fields": [
{
"fieldPath": "expenseId",
"columnName": "expense_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shareOre",
"columnName": "share_ore",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "shareBp",
"columnName": "share_bp",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"expense_id",
"user_id"
]
}
},
{
"tableName": "settlements",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `from_user_id` TEXT NOT NULL, `to_user_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, `settlement_date` TEXT NOT NULL, `note` TEXT NOT NULL, `created_by_user_id` TEXT, `updated_by_user_id` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `deleted_at` TEXT, `server_version` INTEGER, `pending` INTEGER NOT NULL, `pending_mutation_id` TEXT, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fromUserId",
"columnName": "from_user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toUserId",
"columnName": "to_user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "amountOre",
"columnName": "amount_ore",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "settlementDate",
"columnName": "settlement_date",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "note",
"columnName": "note",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdByUserId",
"columnName": "created_by_user_id",
"affinity": "TEXT"
},
{
"fieldPath": "updatedByUserId",
"columnName": "updated_by_user_id",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "TEXT"
},
{
"fieldPath": "serverVersion",
"columnName": "server_version",
"affinity": "INTEGER"
},
{
"fieldPath": "pending",
"columnName": "pending",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pendingMutationId",
"columnName": "pending_mutation_id",
"affinity": "TEXT"
},
{
"fieldPath": "localUpdatedAt",
"columnName": "local_updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_settlements_settlement_date",
"unique": false,
"columnNames": [
"settlement_date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_settlements_settlement_date` ON `${TABLE_NAME}` (`settlement_date`)"
},
{
"name": "index_settlements_pending",
"unique": false,
"columnNames": [
"pending"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_settlements_pending` ON `${TABLE_NAME}` (`pending`)"
}
]
},
{
"tableName": "settlement_allocations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`settlement_id` TEXT NOT NULL, `account_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, PRIMARY KEY(`settlement_id`, `account_id`))",
"fields": [
{
"fieldPath": "settlementId",
"columnName": "settlement_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "account_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "amountOre",
"columnName": "amount_ore",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"settlement_id",
"account_id"
]
},
"indices": [
{
"name": "index_settlement_allocations_account_id",
"unique": false,
"columnNames": [
"account_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_settlement_allocations_account_id` ON `${TABLE_NAME}` (`account_id`)"
}
]
},
{
"tableName": "audit_log",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `entity_type` TEXT NOT NULL, `entity_id` TEXT NOT NULL, `action` TEXT NOT NULL, `source` TEXT NOT NULL, `actor_user_id` TEXT NOT NULL, `client_at` TEXT NOT NULL, `server_at` TEXT NOT NULL, `changes` TEXT NOT NULL, `server_version` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entity_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entity_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actorUserId",
"columnName": "actor_user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "clientAt",
"columnName": "client_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverAt",
"columnName": "server_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "changes",
"columnName": "changes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverVersion",
"columnName": "server_version",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_audit_log_entity_type_entity_id",
"unique": false,
"columnNames": [
"entity_type",
"entity_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_audit_log_entity_type_entity_id` ON `${TABLE_NAME}` (`entity_type`, `entity_id`)"
}
]
},
{
"tableName": "sync_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `epoch` TEXT, `last_success_at` TEXT, `last_attempt_at` TEXT, `last_error_code` TEXT, `clock_skew` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cursor",
"columnName": "cursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "epoch",
"columnName": "epoch",
"affinity": "TEXT"
},
{
"fieldPath": "lastSuccessAt",
"columnName": "last_success_at",
"affinity": "TEXT"
},
{
"fieldPath": "lastAttemptAt",
"columnName": "last_attempt_at",
"affinity": "TEXT"
},
{
"fieldPath": "lastErrorCode",
"columnName": "last_error_code",
"affinity": "TEXT"
},
{
"fieldPath": "clockSkew",
"columnName": "clock_skew",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "sync_rejections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mutation_id` TEXT NOT NULL, `entity_type` TEXT NOT NULL, `entity_id` TEXT NOT NULL, `reason` TEXT NOT NULL, `message` TEXT NOT NULL, `rejected_payload` TEXT NOT NULL, `server_current` TEXT, `rejected_at` TEXT NOT NULL, `dismissed` INTEGER NOT NULL, PRIMARY KEY(`mutation_id`))",
"fields": [
{
"fieldPath": "mutationId",
"columnName": "mutation_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entity_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entity_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reason",
"columnName": "reason",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rejectedPayload",
"columnName": "rejected_payload",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverCurrent",
"columnName": "server_current",
"affinity": "TEXT"
},
{
"fieldPath": "rejectedAt",
"columnName": "rejected_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dismissed",
"columnName": "dismissed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"mutation_id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '294140ea943f5cc1f2c01e4725cac69d')"
]
}
}
@@ -0,0 +1,191 @@
package se.hagstedt.kvitt.data.db
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.Index
import androidx.room3.PrimaryKey
// Room entities of SPEC §14.2. Table and column names are the server's (§14.1); no foreign keys (sync pages may
// deliver a child before its parent). Timestamps are fixed-form RFC 3339 Strings, dates `YYYY-MM-DD`, money Long, bp Int.
// `local_updated_at` is epoch milliseconds on the phone clock, set on every write and never sent.
// 14.2.1 Mirrored tables
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "role") val role: String,
@ColumnInfo(name = "display_name") val displayName: String,
@ColumnInfo(name = "picture_version") val pictureVersion: Int?,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "server_version") val serverVersion: Long,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "accounts", indices = [Index("created_at")])
data class AccountEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "created_by_user_id") val createdByUserId: String,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "archived_at") val archivedAt: String?,
@ColumnInfo(name = "deleted_at") val deletedAt: String?,
@ColumnInfo(name = "server_version") val serverVersion: Long,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "account_default_shares", primaryKeys = ["account_id", "user_id"])
data class AccountDefaultShareEntity(
@ColumnInfo(name = "account_id") val accountId: String,
@ColumnInfo(name = "user_id") val userId: String,
@ColumnInfo(name = "share_bp") val shareBp: Int,
)
@Entity(tableName = "categories")
data class CategoryEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "default_key") val defaultKey: String?,
@ColumnInfo(name = "name") val name: String?,
@ColumnInfo(name = "icon") val icon: String,
@ColumnInfo(name = "color") val color: String,
@ColumnInfo(name = "sort_order") val sortOrder: Int,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "archived_at") val archivedAt: String?,
@ColumnInfo(name = "deleted_at") val deletedAt: String?,
@ColumnInfo(name = "server_version") val serverVersion: Long,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "recurring_templates", indices = [Index("account_id")])
data class RecurringTemplateEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "account_id") val accountId: String,
@ColumnInfo(name = "category_id") val categoryId: String,
@ColumnInfo(name = "amount_ore") val amountOre: Long,
@ColumnInfo(name = "description") val description: String,
@ColumnInfo(name = "notes") val notes: String,
@ColumnInfo(name = "payer_user_id") val payerUserId: String,
@ColumnInfo(name = "split_mode") val splitMode: String,
@ColumnInfo(name = "day_of_month") val dayOfMonth: Int,
@ColumnInfo(name = "start_period") val startPeriod: String,
@ColumnInfo(name = "end_period") val endPeriod: String?,
@ColumnInfo(name = "paused") val paused: Boolean,
@ColumnInfo(name = "last_generated_period") val lastGeneratedPeriod: String?,
@ColumnInfo(name = "created_by_user_id") val createdByUserId: String,
@ColumnInfo(name = "updated_by_user_id") val updatedByUserId: String,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "deleted_at") val deletedAt: String?,
@ColumnInfo(name = "server_version") val serverVersion: Long,
@ColumnInfo(name = "next_occurrence_date") val nextOccurrenceDate: String?,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "recurring_template_splits", primaryKeys = ["template_id", "user_id"])
data class RecurringTemplateSplitEntity(
@ColumnInfo(name = "template_id") val templateId: String,
@ColumnInfo(name = "user_id") val userId: String,
@ColumnInfo(name = "share_bp") val shareBp: Int?,
@ColumnInfo(name = "share_ore") val shareOre: Long?,
)
@Entity(tableName = "expenses", indices = [Index("account_id"), Index("expense_date"), Index("pending")])
data class ExpenseEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "account_id") val accountId: String,
@ColumnInfo(name = "category_id") val categoryId: String,
@ColumnInfo(name = "amount_ore") val amountOre: Long,
@ColumnInfo(name = "description") val description: String,
@ColumnInfo(name = "notes") val notes: String,
@ColumnInfo(name = "expense_date") val expenseDate: String,
@ColumnInfo(name = "payer_user_id") val payerUserId: String,
@ColumnInfo(name = "split_mode") val splitMode: String,
@ColumnInfo(name = "recurring_template_id") val recurringTemplateId: String?,
@ColumnInfo(name = "recurring_period") val recurringPeriod: String?,
@ColumnInfo(name = "created_by_user_id") val createdByUserId: String?,
@ColumnInfo(name = "updated_by_user_id") val updatedByUserId: String?,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "deleted_at") val deletedAt: String?,
@ColumnInfo(name = "server_version") val serverVersion: Long?,
@ColumnInfo(name = "pending") val pending: Boolean,
@ColumnInfo(name = "pending_mutation_id") val pendingMutationId: String?,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "expense_splits", primaryKeys = ["expense_id", "user_id"])
data class ExpenseSplitEntity(
@ColumnInfo(name = "expense_id") val expenseId: String,
@ColumnInfo(name = "user_id") val userId: String,
@ColumnInfo(name = "share_ore") val shareOre: Long,
@ColumnInfo(name = "share_bp") val shareBp: Int?,
)
@Entity(tableName = "settlements", indices = [Index("settlement_date"), Index("pending")])
data class SettlementEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "from_user_id") val fromUserId: String,
@ColumnInfo(name = "to_user_id") val toUserId: String,
@ColumnInfo(name = "amount_ore") val amountOre: Long,
@ColumnInfo(name = "settlement_date") val settlementDate: String,
@ColumnInfo(name = "note") val note: String,
@ColumnInfo(name = "created_by_user_id") val createdByUserId: String?,
@ColumnInfo(name = "updated_by_user_id") val updatedByUserId: String?,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "deleted_at") val deletedAt: String?,
@ColumnInfo(name = "server_version") val serverVersion: Long?,
@ColumnInfo(name = "pending") val pending: Boolean,
@ColumnInfo(name = "pending_mutation_id") val pendingMutationId: String?,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "settlement_allocations", primaryKeys = ["settlement_id", "account_id"], indices = [Index("account_id")])
data class SettlementAllocationEntity(
@ColumnInfo(name = "settlement_id") val settlementId: String,
@ColumnInfo(name = "account_id") val accountId: String,
@ColumnInfo(name = "amount_ore") val amountOre: Long,
)
@Entity(tableName = "audit_log", indices = [Index("entity_type", "entity_id")])
data class AuditEntryEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "entity_type") val entityType: String,
@ColumnInfo(name = "entity_id") val entityId: String,
@ColumnInfo(name = "action") val action: String,
@ColumnInfo(name = "source") val source: String,
@ColumnInfo(name = "actor_user_id") val actorUserId: String,
@ColumnInfo(name = "client_at") val clientAt: String,
@ColumnInfo(name = "server_at") val serverAt: String,
@ColumnInfo(name = "changes") val changes: String,
@ColumnInfo(name = "server_version") val serverVersion: Long,
)
// 14.2.2 Local-only tables
@Entity(tableName = "sync_state")
data class SyncStateEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: Int,
@ColumnInfo(name = "cursor") val cursor: Long,
@ColumnInfo(name = "epoch") val epoch: String?,
@ColumnInfo(name = "last_success_at") val lastSuccessAt: String?,
@ColumnInfo(name = "last_attempt_at") val lastAttemptAt: String?,
@ColumnInfo(name = "last_error_code") val lastErrorCode: String?,
@ColumnInfo(name = "clock_skew") val clockSkew: Boolean,
)
@Entity(tableName = "sync_rejections")
data class SyncRejectionEntity(
@PrimaryKey @ColumnInfo(name = "mutation_id") val mutationId: String,
@ColumnInfo(name = "entity_type") val entityType: String,
@ColumnInfo(name = "entity_id") val entityId: String,
@ColumnInfo(name = "reason") val reason: String,
@ColumnInfo(name = "message") val message: String,
@ColumnInfo(name = "rejected_payload") val rejectedPayload: String,
@ColumnInfo(name = "server_current") val serverCurrent: String?,
@ColumnInfo(name = "rejected_at") val rejectedAt: String,
@ColumnInfo(name = "dismissed") val dismissed: Boolean,
)
@@ -0,0 +1,33 @@
package se.hagstedt.kvitt.data.db
import androidx.room3.Database
import androidx.room3.RoomDatabase
/**
* The phone's database (SPEC §14.2): file `kvitt.db`, version 1, schema exported to `android/data/schemas/`.
* DAOs, the builder and the SQLite driver are added by M5. Every later version adds a migration and its test.
*/
@Database(
entities = [
UserEntity::class,
AccountEntity::class,
AccountDefaultShareEntity::class,
CategoryEntity::class,
RecurringTemplateEntity::class,
RecurringTemplateSplitEntity::class,
ExpenseEntity::class,
ExpenseSplitEntity::class,
SettlementEntity::class,
SettlementAllocationEntity::class,
AuditEntryEntity::class,
SyncStateEntity::class,
SyncRejectionEntity::class,
],
version = 1,
exportSchema = true,
)
abstract class KvittDatabase : RoomDatabase() {
companion object {
const val FILE_NAME = "kvitt.db"
}
}