Milestones 0 and 1: foundation and money in (20261007.10)

Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite),
local sign-in with long-lived sessions and passkeys, roles (read-only, full
access, admin), nightly and pre-upgrade backups, light and dark themes,
date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED.

Money in: accounts with balances, CSV import with bank detection, a column
mapping pop-up for unknown layouts, duplicate checks, transfer pairing and
undo; one-tap review with learned rules; activity with search, splits, tags,
notes and cash entries. Statement files are blocked from commits by a
pre-commit hook; tests use synthetic fixtures only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 19:52:07 -04:00
commit c3cf0f3eb8
147 changed files with 21713 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
CREATE TABLE `change_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`at` text NOT NULL,
`user_id` integer,
`action` text NOT NULL,
`entity` text NOT NULL,
`entity_id` text,
`detail` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `change_log_at_idx` ON `change_log` (`at`);--> statement-breakpoint
CREATE TABLE `idea` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`title` text NOT NULL,
`description` text DEFAULT '' NOT NULL,
`status` text DEFAULT 'new' NOT NULL,
`admin_note` text,
`path` text,
`app_version` text,
`user_agent` text,
`user_id` integer,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idea_status_idx` ON `idea` (`status`);--> statement-breakpoint
CREATE TABLE `passkey` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL,
`public_key` blob NOT NULL,
`counter` integer DEFAULT 0 NOT NULL,
`transports` text,
`device_type` text,
`backed_up` integer DEFAULT false NOT NULL,
`created_at` text NOT NULL,
`last_used_at` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `passkey_user_idx` ON `passkey` (`user_id`);--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`kind` text DEFAULT 'browser' NOT NULL,
`device_label` text NOT NULL,
`user_agent` text,
`ip` text,
`created_at` text NOT NULL,
`last_seen_at` text NOT NULL,
`expires_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `session_user_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE TABLE `setting` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`display_name` text NOT NULL,
`password_hash` text NOT NULL,
`access` text DEFAULT 'full' NOT NULL,
`is_admin` integer DEFAULT false NOT NULL,
`theme` text DEFAULT 'system' NOT NULL,
`must_change_password` integer DEFAULT false NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`deleted_at` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);

View File

@@ -0,0 +1,145 @@
CREATE TABLE `account` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`kind` text NOT NULL,
`institution` text,
`last4` text,
`color` text DEFAULT 'indigo' NOT NULL,
`status` text DEFAULT 'open' NOT NULL,
`balance_cents` integer DEFAULT 0 NOT NULL,
`balance_as_of` text,
`balance_source` text,
`layout_key` text,
`file_name_pattern` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`retired_at` text
);
--> statement-breakpoint
CREATE TABLE `category` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`group_id` integer NOT NULL,
`name` text NOT NULL,
`kind` text DEFAULT 'expense' NOT NULL,
`icon` text DEFAULT 'circle' NOT NULL,
`color` text DEFAULT 'slate' NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`group_id`) REFERENCES `category_group`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `category_group_idx` ON `category` (`group_id`);--> statement-breakpoint
CREATE TABLE `category_group` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE `import_layout` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`signature` text NOT NULL,
`config` text NOT NULL,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `import` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`account_id` integer NOT NULL,
`file_name` text NOT NULL,
`file_hash` text NOT NULL,
`layout_key` text NOT NULL,
`date_from` text,
`date_to` text,
`row_count` integer DEFAULT 0 NOT NULL,
`added_count` integer DEFAULT 0 NOT NULL,
`duplicate_count` integer DEFAULT 0 NOT NULL,
`skipped_count` integer DEFAULT 0 NOT NULL,
`transfer_count` integer DEFAULT 0 NOT NULL,
`status` text DEFAULT 'committed' NOT NULL,
`previous_balance` text,
`user_id` integer,
`created_at` text NOT NULL,
`undone_at` text,
FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `import_account_idx` ON `import` (`account_id`);--> statement-breakpoint
CREATE TABLE `rule` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`merchant` text NOT NULL,
`account_id` integer,
`category_id` integer,
`rename_to` text,
`source` text DEFAULT 'learned' NOT NULL,
`hits` integer DEFAULT 0 NOT NULL,
`created_at` text NOT NULL,
`last_used_at` text,
FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `rule_merchant_idx` ON `rule` (`merchant`);--> statement-breakpoint
CREATE TABLE `split` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`transaction_id` integer NOT NULL,
`category_id` integer NOT NULL,
`amount_cents` integer NOT NULL,
`note` text,
FOREIGN KEY (`transaction_id`) REFERENCES `txn`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `split_txn_idx` ON `split` (`transaction_id`);--> statement-breakpoint
CREATE INDEX `split_category_idx` ON `split` (`category_id`);--> statement-breakpoint
CREATE TABLE `tag` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`color` text DEFAULT 'slate' NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `tag_name_unique` ON `tag` (`name`);--> statement-breakpoint
CREATE TABLE `txn_tag` (
`transaction_id` integer NOT NULL,
`tag_id` integer NOT NULL,
FOREIGN KEY (`transaction_id`) REFERENCES `txn`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `txn_tag_pk` ON `txn_tag` (`transaction_id`,`tag_id`);--> statement-breakpoint
CREATE INDEX `txn_tag_tag_idx` ON `txn_tag` (`tag_id`);--> statement-breakpoint
CREATE TABLE `txn` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`account_id` integer NOT NULL,
`import_id` integer,
`date` text NOT NULL,
`posted_date` text,
`amount_cents` integer NOT NULL,
`description` text NOT NULL,
`merchant` text NOT NULL,
`category_id` integer,
`needs_review` integer DEFAULT true NOT NULL,
`suggested_by` text,
`is_split` integer DEFAULT false NOT NULL,
`bank_category` text,
`bank_type` text,
`note` text,
`transfer_peer_id` integer,
`source` text DEFAULT 'import' NOT NULL,
`dedupe_key` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`import_id`) REFERENCES `import`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `txn_account_date_idx` ON `txn` (`account_id`,`date`);--> statement-breakpoint
CREATE INDEX `txn_date_idx` ON `txn` (`date`);--> statement-breakpoint
CREATE INDEX `txn_category_date_idx` ON `txn` (`category_id`,`date`);--> statement-breakpoint
CREATE INDEX `txn_merchant_idx` ON `txn` (`merchant`);--> statement-breakpoint
CREATE INDEX `txn_dedupe_idx` ON `txn` (`dedupe_key`);--> statement-breakpoint
CREATE INDEX `txn_review_idx` ON `txn` (`needs_review`);--> statement-breakpoint
CREATE INDEX `txn_import_idx` ON `txn` (`import_id`);

View File

@@ -0,0 +1,533 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1d213858-0a17-4e4b-bce5-77f99e612d9f",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"change_log": {
"name": "change_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"at": {
"name": "at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity": {
"name": "entity",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"change_log_at_idx": {
"name": "change_log_at_idx",
"columns": [
"at"
],
"isUnique": false
}
},
"foreignKeys": {
"change_log_user_id_user_id_fk": {
"name": "change_log_user_id_user_id_fk",
"tableFrom": "change_log",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"idea": {
"name": "idea",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'new'"
},
"admin_note": {
"name": "admin_note",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"app_version": {
"name": "app_version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idea_status_idx": {
"name": "idea_status_idx",
"columns": [
"status"
],
"isUnique": false
}
},
"foreignKeys": {
"idea_user_id_user_id_fk": {
"name": "idea_user_id_user_id_fk",
"tableFrom": "idea",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"passkey": {
"name": "passkey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"device_type": {
"name": "device_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"backed_up": {
"name": "backed_up",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_used_at": {
"name": "last_used_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"passkey_user_idx": {
"name": "passkey_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"passkey_user_id_user_id_fk": {
"name": "passkey_user_id_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'browser'"
},
"device_label": {
"name": "device_label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_user_idx": {
"name": "session_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setting": {
"name": "setting",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access": {
"name": "access",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'full'"
},
"is_admin": {
"name": "is_admin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"theme": {
"name": "theme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'system'"
},
"must_change_password": {
"name": "must_change_password",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1791402989316,
"tag": "0000_foundation",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1791404838766,
"tag": "0001_money_in",
"breakpoints": true
}
]
}