refactor(db): adopt SeaORM 2.0 entity-first migration strategy
Delete lib/db/migrations/ (7 documentation-only SQL files; never executed
per the project's "数据库就不要动了" policy). Enable `schema-sync` +
`entity-registry` features on the db crate and wire the dormant
`auto_migrate` config flag into `Db::from_config` so every startup syncs
the registered `db::*` entities against the database.
`schema_extras.sql` is the single hand-maintained SQL file kept for the
constraints entity attributes cannot express: partial/overlapping unique
indexes (issue_watch, issue_reaction, project_favorites expression),
composite non-unique indexes, polymorphic XOR CHECK constraints, and
drops of the legacy channel_members / chat_members tables. Idempotent
(IF NOT EXISTS / DO-block guards).
project_links gains the composite unique_key (project_id,
related_project_id, kind) so `sync` does not drop it on existing DBs.
Tests:
- registry_prefix_matches_module: verifies the `"db::"` prefix matches
by module_path.
- entity_first_sync_converges_on_sqlite: representative cross-module
entities sync twice idempotently with FKs inlined topologically.
zhenyi committed
Deleted lib/db/migrations/sql/0099_repo_ext.sql lib/db/migrations/sql/0099_repo_ext.sql +0 −18
@@ -1,18 +0,0 @@
1-- 0099_repo_ext.sql
2-- Reference DDL for repo table extensions added by lib/repo.
3-- This file is documentation only; it is NOT executed by the SeaORM migration runner
4-- per project policy ("数据库就不要动了"). Apply manually when DBA window allows.
5--
6-- Adds five columns mirroring the Rust fields declared in lib/db/repo/repos.rs:
7-- allow_rebase : bool default true
8-- allow_rebase_explicit : bool default false
9-- allow_squash_merge : bool default true
10-- allow_merge_commits : bool default true
11-- object_format : text default 'sha1'
12
13ALTER TABLE repos
14 ADD COLUMN IF NOT EXISTS allow_rebase boolean NOT NULL DEFAULT true,
15 ADD COLUMN IF NOT EXISTS allow_rebase_explicit boolean NOT NULL DEFAULT false,
16 ADD COLUMN IF NOT EXISTS allow_squash_merge boolean NOT NULL DEFAULT true,
17 ADD COLUMN IF NOT EXISTS allow_merge_commits boolean NOT NULL DEFAULT true,
18 ADD COLUMN IF NOT EXISTS object_format text NOT NULL DEFAULT 'sha1';
Deleted lib/db/migrations/sql/0100_issue_comment_parent_id.sql lib/db/migrations/sql/0100_issue_comment_parent_id.sql +0 −2
@@ -1,2 +0,0 @@
1ALTER TABLE comment ADD COLUMN IF NOT EXISTS parent_id UUID NULL;
2CREATE INDEX IF NOT EXISTS idx_comment_parent_id ON comment (parent_id);
No newline at end of file
Deleted lib/db/migrations/sql/0101_issue_unique_indexes.sql lib/db/migrations/sql/0101_issue_unique_indexes.sql +0 −4
@@ -1,4 +0,0 @@
1-- This file is documentation only; it is NOT executed by the SeaORM migration runner per project policy ('数据库就不要动了'). Apply manually when DBA window allows.
2CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_watch_issue_user ON issue_watch (issue_id, user_id);
3CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_reaction_issue ON issue_reaction (issue_id, user_id, emoji) WHERE issue_id IS NOT NULL;
4CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_reaction_comment ON issue_reaction (comment_id, user_id, emoji) WHERE comment_id IS NOT NULL;
No newline at end of file
Deleted lib/db/migrations/sql/0102_project_linear_ext.sql lib/db/migrations/sql/0102_project_linear_ext.sql +0 −49
@@ -1,49 +0,0 @@
1-- 0102_project_linear_ext.sql
2-- Reference DDL for lib/db/project/project.rs Linear-style field additions.
3-- Documentation only; not run by the migration runner per project policy
4-- ("数据库就不要动了"). Apply manually when DBA window allows.
5--
6-- Mirrors the Rust fields declared in lib/db/project/project.rs:
7-- lead_id : uuid (FK users.uid ON DELETE SET NULL)
8-- icon : text
9-- color : text
10-- state : smallint (ProjectState: 0=Backlog, 1=Planned, 2=InProgress,
11-- 3=Paused, 4=Completed, 5=Cancelled)
12-- target_date : date
13-- started_at : timestamptz
14-- completed_at : timestamptz
15-- cancelled_at : timestamptz
16-- health : smallint (ProjectHealth: 0=OnTrack, 1=AtRisk, 2=OffTrack)
17-- sort_order : double precision default 0
18-- starred : integer default 0
19--
20-- Drop:
21-- is_closed : superseded by state enum
22
23ALTER TABLE project
24 ADD COLUMN IF NOT EXISTS lead_id uuid REFERENCES users(uid) ON DELETE SET NULL,
25 ADD COLUMN IF NOT EXISTS icon text,
26 ADD COLUMN IF NOT EXISTS color text,
27 ADD COLUMN IF NOT EXISTS state smallint NOT NULL DEFAULT 0,
28 ADD COLUMN IF NOT EXISTS target_date date,
29 ADD COLUMN IF NOT EXISTS started_at timestamptz,
30 ADD COLUMN IF NOT EXISTS completed_at timestamptz,
31 ADD COLUMN IF NOT EXISTS cancelled_at timestamptz,
32 ADD COLUMN IF NOT EXISTS health smallint,
33 ADD COLUMN IF NOT EXISTS sort_order double precision NOT NULL DEFAULT 0,
34 ADD COLUMN IF NOT EXISTS starred integer NOT NULL DEFAULT 0;
35
36-- Backfill is_closed -> state before dropping the column to preserve history:
37-- is_closed = true -> state = 5 (Cancelled)
38-- is_closed = false -> state = 0 (Backlog)
39UPDATE project
40 SET state = CASE WHEN is_closed THEN 5 ELSE 0 END
41 WHERE state = 0
42 AND is_closed IS NOT NULL;
43
44ALTER TABLE project DROP COLUMN IF EXISTS is_closed;
45
46CREATE INDEX IF NOT EXISTS idx_project_state ON project (state);
47CREATE INDEX IF NOT EXISTS idx_project_target_date ON project (target_date);
48CREATE INDEX IF NOT EXISTS idx_project_space_order ON project (space_id, sort_order);
49CREATE INDEX IF NOT EXISTS idx_project_lead ON project (lead_id);
Deleted lib/db/migrations/sql/0103_project_linear_entities.sql lib/db/migrations/sql/0103_project_linear_entities.sql +0 −110
@@ -1,110 +0,0 @@
1-- 0103_project_linear_entities.sql
2-- Reference DDL for new project entities declared in lib/db/project/:
3-- project_members, project_views, project_documents,
4-- project_links, project_activities, project_favorites.
5-- Documentation only; not run by the migration runner per project policy.
6
7-- ---------------------------------------------------------------------------
8-- project_members
9-- ---------------------------------------------------------------------------
10CREATE TABLE IF NOT EXISTS project_members (
11 id uuid PRIMARY KEY,
12 project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
13 user_id uuid NOT NULL REFERENCES users(uid) ON DELETE CASCADE,
14 role smallint NOT NULL DEFAULT 2, -- ProjectMemberRole
15 joined_at timestamptz NOT NULL DEFAULT now(),
16 updated_at timestamptz,
17 UNIQUE (project_id, user_id)
18);
19CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members (user_id);
20CREATE INDEX IF NOT EXISTS idx_project_members_project ON project_members (project_id);
21
22-- ---------------------------------------------------------------------------
23-- project_views (saved filters / sort / group / layout)
24-- ---------------------------------------------------------------------------
25CREATE TABLE IF NOT EXISTS project_views (
26 id uuid PRIMARY KEY,
27 project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
28 creator_id uuid NOT NULL REFERENCES users(uid),
29 name text NOT NULL,
30 description text,
31 filter jsonb NOT NULL DEFAULT '{}'::jsonb,
32 sort_by text NOT NULL DEFAULT 'manual',
33 group_by text,
34 layout text NOT NULL DEFAULT 'list',
35 is_shared boolean NOT NULL DEFAULT false,
36 sort_order integer NOT NULL DEFAULT 0,
37 created_at timestamptz NOT NULL DEFAULT now(),
38 updated_at timestamptz
39);
40CREATE INDEX IF NOT EXISTS idx_project_views_project ON project_views (project_id, sort_order);
41CREATE INDEX IF NOT EXISTS idx_project_views_creator ON project_views (creator_id);
42
43-- ---------------------------------------------------------------------------
44-- project_documents (Linear Documents — Markdown text for now)
45-- ---------------------------------------------------------------------------
46CREATE TABLE IF NOT EXISTS project_documents (
47 id uuid PRIMARY KEY,
48 project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
49 parent_id uuid REFERENCES project_documents(id) ON DELETE SET NULL,
50 creator_id uuid NOT NULL REFERENCES users(uid),
51 last_editor_id uuid REFERENCES users(uid),
52 title text NOT NULL,
53 slug text NOT NULL,
54 content text NOT NULL DEFAULT '',
55 icon text,
56 created_at timestamptz NOT NULL DEFAULT now(),
57 updated_at timestamptz,
58 UNIQUE (project_id, slug)
59);
60CREATE INDEX IF NOT EXISTS idx_project_documents_project ON project_documents (project_id);
61CREATE INDEX IF NOT EXISTS idx_project_documents_parent ON project_documents (parent_id);
62
63-- ---------------------------------------------------------------------------
64-- project_links (cross-project relations)
65-- ---------------------------------------------------------------------------
66CREATE TABLE IF NOT EXISTS project_links (
67 id uuid PRIMARY KEY,
68 project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
69 related_project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
70 kind smallint NOT NULL, -- ProjectLinkKind
71 created_by uuid NOT NULL REFERENCES users(uid),
72 created_at timestamptz NOT NULL DEFAULT now(),
73 CHECK (project_id <> related_project_id),
74 UNIQUE (project_id, related_project_id, kind)
75);
76CREATE INDEX IF NOT EXISTS idx_project_links_project ON project_links (project_id);
77CREATE INDEX IF NOT EXISTS idx_project_links_related_project ON project_links (related_project_id);
78
79-- ---------------------------------------------------------------------------
80-- project_activities (aggregated activity feed)
81-- ---------------------------------------------------------------------------
82CREATE TABLE IF NOT EXISTS project_activities (
83 id uuid PRIMARY KEY,
84 project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
85 actor_id uuid NOT NULL REFERENCES users(uid),
86 verb text NOT NULL,
87 target_kind text NOT NULL,
88 target_id uuid,
89 payload jsonb,
90 created_at timestamptz NOT NULL DEFAULT now()
91);
92CREATE INDEX IF NOT EXISTS idx_project_activities_feed
93 ON project_activities (project_id, created_at DESC);
94CREATE INDEX IF NOT EXISTS idx_project_activities_actor
95 ON project_activities (actor_id, created_at DESC);
96
97-- ---------------------------------------------------------------------------
98-- project_favorites (per-user star / favorites folder)
99-- ---------------------------------------------------------------------------
100CREATE TABLE IF NOT EXISTS project_favorites (
101 id uuid PRIMARY KEY,
102 project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
103 user_id uuid NOT NULL REFERENCES users(uid) ON DELETE CASCADE,
104 folder text,
105 created_at timestamptz NOT NULL DEFAULT now()
106);
107CREATE UNIQUE INDEX IF NOT EXISTS idx_project_favorites_unique
108 ON project_favorites (project_id, user_id, COALESCE(folder, ''));
109CREATE INDEX IF NOT EXISTS idx_project_favorites_user
110 ON project_favorites (user_id, created_at DESC);
Deleted lib/db/migrations/sql/0104_issue_polymorphic_project.sql lib/db/migrations/sql/0104_issue_polymorphic_project.sql +0 −66
@@ -1,66 +0,0 @@
1-- 0104_issue_polymorphic_project.sql
2-- Make issue collaboration dimensions (comment / reaction / watch / event_line
3-- / pin) polymorphic between issue and project. Each row must reference
4-- exactly one of (issue_id, comment_id for reactions, project_id).
5--
6-- Documentation only; not run by the migration runner per project policy.
7
8-- ---------------------------------------------------------------------------
9-- comment : (issue_id XOR project_id)
10-- ---------------------------------------------------------------------------
11ALTER TABLE comment
12 ALTER COLUMN issue_id DROP NOT NULL,
13 ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES project(id) ON DELETE CASCADE,
14 ADD CONSTRAINT comment_target_xor
15 CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL));
16CREATE INDEX IF NOT EXISTS idx_comment_project ON comment (project_id);
17
18-- ---------------------------------------------------------------------------
19-- issue_reaction : exactly one of (issue_id, comment_id, project_id)
20-- ---------------------------------------------------------------------------
21ALTER TABLE issue_reaction
22 ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES project(id) ON DELETE CASCADE,
23 ADD CONSTRAINT issue_reaction_target_exactly_one
24 CHECK (
25 ((issue_id IS NOT NULL)::int +
26 (comment_id IS NOT NULL)::int +
27 (project_id IS NOT NULL)::int) = 1
28 );
29CREATE INDEX IF NOT EXISTS idx_issue_reaction_project ON issue_reaction (project_id);
30
31-- ---------------------------------------------------------------------------
32-- issue_watch : (issue_id XOR project_id)
33-- ---------------------------------------------------------------------------
34ALTER TABLE issue_watch
35 ALTER COLUMN issue_id DROP NOT NULL,
36 ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES project(id) ON DELETE CASCADE,
37 ADD CONSTRAINT issue_watch_target_xor
38 CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL));
39CREATE INDEX IF NOT EXISTS idx_issue_watch_project ON issue_watch (project_id);
40
41-- Rebuild unique indexes with partial filter so nullable columns are handled:
42DROP INDEX IF EXISTS idx_issue_watch_issue_user;
43CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_watch_issue_user
44 ON issue_watch (issue_id, user_id) WHERE issue_id IS NOT NULL;
45CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_watch_project_user
46 ON issue_watch (project_id, user_id) WHERE project_id IS NOT NULL;
47
48-- ---------------------------------------------------------------------------
49-- issue_event_line : (issue_id XOR project_id)
50-- ---------------------------------------------------------------------------
51ALTER TABLE issue_event_line
52 ALTER COLUMN issue_id DROP NOT NULL,
53 ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES project(id) ON DELETE CASCADE,
54 ADD CONSTRAINT issue_event_target_xor
55 CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL));
56CREATE INDEX IF NOT EXISTS idx_issue_event_project ON issue_event_line (project_id);
57
58-- ---------------------------------------------------------------------------
59-- issue_pin : (issue_id XOR project_id)
60-- ---------------------------------------------------------------------------
61ALTER TABLE issue_pin
62 ALTER COLUMN issue_id DROP NOT NULL,
63 ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES project(id) ON DELETE CASCADE,
64 ADD CONSTRAINT issue_pin_target_xor
65 CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL));
66CREATE INDEX IF NOT EXISTS idx_issue_pin_project ON issue_pin (project_id);
Deleted lib/db/migrations/sql/0105_channel_tree_and_drop_membership.sql lib/db/migrations/sql/0105_channel_tree_and_drop_membership.sql +0 −23
@@ -1,23 +0,0 @@
1-- 0105_channel_tree_and_drop_membership.sql
2-- Reference DDL for the channel tree refactor.
3-- This file is documentation only; it is NOT executed by the SeaORM migration runner
4-- per project policy ("数据库就不要动了"). Apply manually when DBA window allows.
5--
6-- Changes:
7-- 1. channel gains a `kind` discriminator: 0 = Directory (container), 1 = Object (leaf).
8-- Existing rows are all legacy resource anchors -> backfilled as Object (1). New
9-- Directory rows are minted explicitly by the channels feature going forward.
10-- 2. chat_reads becomes the single per-user chat state table; it absorbs `muted`
11-- (the former chat_member.muted) alongside the existing read cursor.
12-- 3. project gains an optional `channel_id` binding (nullable until the project enters
13-- the channel tree through create_project(parent_dir)).
14-- 4. channel_members and chat_members membership tables are dropped: access now
15-- inherits from space membership (space_members.status == Active); per-node ACL is
16-- expressed exclusively via channel_overwrite_role / channel_overwrite_user.
17--
18ALTER TABLE channel ADD COLUMN kind SMALLINT NOT NULL DEFAULT 0;
19UPDATE channel SET kind = 1;
20ALTER TABLE chat_reads ADD COLUMN muted BOOLEAN NOT NULL DEFAULT FALSE;
21ALTER TABLE project ADD COLUMN channel_id UUID NULL;
22DROP TABLE channel_members;
23DROP TABLE chat_members;
No newline at end of file
Modified lib/db/project/project_links.rs lib/db/project/project_links.rs +3 −0 View lib/db/project/project_links.rs
@@ -18,8 +18,11 @@
1818pub struct Model {
1919 #[sea_orm(primary_key, auto_increment = false)]
2020 pub id: Uuid,
21 #[sea_orm(unique_key = "project_link")]
2122 pub project_id: Uuid,
23 #[sea_orm(unique_key = "project_link")]
2224 pub related_project_id: Uuid,
25 #[sea_orm(unique_key = "project_link")]
2326 pub kind: ProjectLinkKind,
2427 pub created_by: Uuid,
2528 pub created_at: OffsetDateTime,
Modified lib/db/Cargo.toml lib/db/Cargo.toml +1 −1 View lib/db/Cargo.toml
@@ -12,7 +12,7 @@
1212path = "lib.rs"
1313name = "db"
1414[dependencies]
15sea-orm = { workspace = true }
15sea-orm = { workspace = true, features = ["schema-sync", "entity-registry"] }
1616serde = { workspace = true }
1717time = { workspace = true }
1818uuid = { workspace = true }
Modified lib/db/conn.rs lib/db/conn.rs +7 −0 View lib/db/conn.rs
@@ -32,6 +32,9 @@
3232 #[source]
3333 source: DbErr,
3434 },
35
36 #[error("schema sync failed: {0}")]
37 SchemaSync(#[source] DbErr),
3538}
3639
3740#[derive(Clone)]
@@ -58,6 +61,10 @@
5861 .map_err(DbError::PrimaryPool)?;
5962 check_connectivity(&primary, "primary").await?;
6063
64 if cfg.auto_migrate {
65 crate::schema::sync_schema(&primary).await?;
66 }
67
6168 let mut replicas = Vec::with_capacity(cfg.replicas.len());
6269 for (i, rc) in cfg.replicas.iter().enumerate() {
6370 match build_connection(
Modified lib/db/lib.rs lib/db/lib.rs +1 −0 View lib/db/lib.rs
@@ -21,3 +21,4 @@
2121
2222pub mod config;
2323pub mod conn;
24pub mod schema;
Added lib/db/schema.rs lib/db/schema.rs +114 −0 View lib/db/schema.rs
@@ -0,0 +1,114 @@
1use crate::conn::DbError;
2use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection};
3
4/// Entity-first schema bootstrap.
5///
6/// 1. `sync` the registered `db::*` entities against the database: creates any
7/// missing tables, columns, foreign keys and entity-declared indexes,
8/// idempotently and non-destructively (it never drops tables/columns).
9/// 2. For PostgreSQL, apply [`schema_extras.sql`] — the idempotent DDL for the
10/// constraints entity attributes cannot express (partial/overlapping unique
11/// indexes, composite non-unique indexes, CHECK constraints, legacy table
12/// drops). Kept in a single hand-maintained SQL file per project decision.
13///
14/// Gated at the call site by [`crate::config::AppDatabaseSettings::auto_migrate`],
15/// so production can keep the old DBA-managed policy by leaving the flag off.
16pub async fn sync_schema(db: &DatabaseConnection) -> Result<(), DbError> {
17 db.get_schema_registry("db::")
18 .sync(db)
19 .await
20 .map_err(DbError::SchemaSync)?;
21
22 if db.get_database_backend() == DatabaseBackend::Postgres {
23 db.execute_unprepared(include_str!("schema_extras.sql"))
24 .await
25 .map_err(DbError::SchemaSync)?;
26 }
27
28 Ok(())
29}
30
31#[cfg(test)]
32mod tests {
33 use super::*;
34 use sea_orm::{Database, Statement};
35
36 async fn table_exists(db: &DatabaseConnection, name: &str) -> bool {
37 db.query_one_raw(Statement::from_sql_and_values(
38 DatabaseBackend::Sqlite,
39 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
40 [name.into()],
41 ))
42 .await
43 .unwrap()
44 .is_some()
45 }
46
47 /// The registry prefix `"db::"` matches entities by `module_path` prefix.
48 /// Verify against a SQLite-compatible module (the full `db::*` registry is
49 /// not SQLite-creatable: `user_oauth_provider.scopes: Vec<String>` maps to
50 /// a Postgres-only `Array` column).
51 #[tokio::test]
52 async fn registry_prefix_matches_module() {
53 let db = Database::connect("sqlite::memory:").await.unwrap();
54
55 db.get_schema_registry("db::channel::").sync(&db).await.unwrap();
56 assert!(table_exists(&db, "channel").await);
57 assert!(table_exists(&db, "channel_overwrite_role").await);
58
59 // A wrong prefix must not register anything.
60 db.get_schema_registry("db::nope::").sync(&db).await.unwrap();
61 assert!(!table_exists(&db, "project").await);
62 }
63
64 /// Representative cross-module set: converges idempotently and orders
65 /// tables topologically (FKs are inline on SQLite).
66 #[tokio::test]
67 async fn entity_first_sync_converges_on_sqlite() {
68 use crate::{
69 channel::channels as channel_entity,
70 chat::{chat_reads as read_entity, chats as chat_entity},
71 issue::{issue_reactions as reaction_entity, issue_watch as watch_entity},
72 project::project as project_entity,
73 user::users as user_entity,
74 };
75
76 let db = Database::connect("sqlite::memory:").await.unwrap();
77
78 // First pass creates the tables; second pass must also succeed and
79 // make no destructive changes (idempotent convergence).
80 db.get_schema_builder()
81 .register(user_entity::Entity)
82 .register(channel_entity::Entity)
83 .register(project_entity::Entity)
84 .register(chat_entity::Entity)
85 .register(read_entity::Entity)
86 .register(watch_entity::Entity)
87 .register(reaction_entity::Entity)
88 .sync(&db)
89 .await
90 .unwrap();
91 db.get_schema_builder()
92 .register(user_entity::Entity)
93 .register(channel_entity::Entity)
94 .register(project_entity::Entity)
95 .register(chat_entity::Entity)
96 .register(read_entity::Entity)
97 .register(watch_entity::Entity)
98 .register(reaction_entity::Entity)
99 .sync(&db)
100 .await
101 .unwrap();
102
103 for table in [
104 "channel",
105 "project",
106 "chat_reads",
107 "issue_watch",
108 "issue_reaction",
109 "users",
110 ] {
111 assert!(table_exists(&db, table).await, "table `{table}` should exist after sync");
112 }
113 }
114}
No newline at end of file
Added lib/db/schema_extras.sql lib/db/schema_extras.sql +111 −0 View lib/db/schema_extras.sql
@@ -0,0 +1,111 @@
1-- schema_extras.sql
2-- Idempotent PostgreSQL DDL for constraints that SeaORM entity-first `sync`
3-- cannot express via entity attributes. Runs after every `sync` when
4-- auto_migrate is enabled (see lib/db/schema.rs). Safe to re-run on every
5-- startup; every statement is guarded with IF NOT EXISTS / DO-block checks.
6--
7-- This is the single hand-maintained SQL file kept after the old
8-- lib/db/migrations/ folder was removed (entity-first strategy).
9
10-- ---------------------------------------------------------------------------
11-- Legacy tables dropped by the channel-tree refactor (no entities remain).
12-- `sync` is non-destructive, so these must be dropped here.
13-- ---------------------------------------------------------------------------
14DROP TABLE IF EXISTS channel_members;
15DROP TABLE IF EXISTS chat_members;
16
17-- ---------------------------------------------------------------------------
18-- Partial unique indexes (polymorphic targets, WHERE-clause filtered).
19-- Not expressible as #[sea_orm(unique_key)] — overlapping columns and partial
20-- predicates have no entity attribute. `sync` drops undeclared unique indexes,
21-- so these are recreated here on every startup.
22-- ---------------------------------------------------------------------------
23CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_watch_issue_user
24 ON issue_watch (issue_id, user_id) WHERE issue_id IS NOT NULL;
25CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_watch_project_user
26 ON issue_watch (project_id, user_id) WHERE project_id IS NOT NULL;
27CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_reaction_issue
28 ON issue_reaction (issue_id, user_id, emoji) WHERE issue_id IS NOT NULL;
29CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_reaction_comment
30 ON issue_reaction (comment_id, user_id, emoji) WHERE comment_id IS NOT NULL;
31CREATE UNIQUE INDEX IF NOT EXISTS idx_project_favorites_unique
32 ON project_favorites (project_id, user_id, COALESCE(folder, ''));
33
34-- ---------------------------------------------------------------------------
35-- Composite non-unique indexes (entity attributes are single-column only).
36-- ---------------------------------------------------------------------------
37CREATE INDEX IF NOT EXISTS idx_project_space_order ON project (space_id, sort_order);
38CREATE INDEX IF NOT EXISTS idx_project_views_project ON project_views (project_id, sort_order);
39CREATE INDEX IF NOT EXISTS idx_project_activities_feed ON project_activities (project_id, created_at DESC);
40CREATE INDEX IF NOT EXISTS idx_project_activities_actor ON project_activities (actor_id, created_at DESC);
41CREATE INDEX IF NOT EXISTS idx_project_favorites_user ON project_favorites (user_id, created_at DESC);
42
43-- Single-column indexes from the removed migrations (parity on fresh DBs).
44CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members (user_id);
45CREATE INDEX IF NOT EXISTS idx_project_members_project ON project_members (project_id);
46CREATE INDEX IF NOT EXISTS idx_project_views_creator ON project_views (creator_id);
47CREATE INDEX IF NOT EXISTS idx_project_documents_project ON project_documents (project_id);
48CREATE INDEX IF NOT EXISTS idx_project_documents_parent ON project_documents (parent_id);
49CREATE INDEX IF NOT EXISTS idx_project_links_project ON project_links (project_id);
50CREATE INDEX IF NOT EXISTS idx_project_links_related_project ON project_links (related_project_id);
51CREATE INDEX IF NOT EXISTS idx_project_state ON project (state);
52CREATE INDEX IF NOT EXISTS idx_project_target_date ON project (target_date);
53CREATE INDEX IF NOT EXISTS idx_project_lead ON project (lead_id);
54CREATE INDEX IF NOT EXISTS idx_comment_parent_id ON comment (parent_id);
55CREATE INDEX IF NOT EXISTS idx_comment_project ON comment (project_id);
56CREATE INDEX IF NOT EXISTS idx_issue_reaction_project ON issue_reaction (project_id);
57CREATE INDEX IF NOT EXISTS idx_issue_watch_project ON issue_watch (project_id);
58CREATE INDEX IF NOT EXISTS idx_issue_event_project ON issue_event_line (project_id);
59CREATE INDEX IF NOT EXISTS idx_issue_pin_project ON issue_pin (project_id);
60
61-- ---------------------------------------------------------------------------
62-- CHECK constraints (polymorphic XOR / exactly-one targets).
63-- ALTER TABLE ... ADD CONSTRAINT has no IF NOT EXISTS, so guard each with a
64-- DO block against pg_constraint.
65-- ---------------------------------------------------------------------------
66DO $$ BEGIN
67 IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'comment_target_xor') THEN
68 ALTER TABLE comment
69 ADD CONSTRAINT comment_target_xor
70 CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL));
71 END IF;
72END $$;
73
74DO $$ BEGIN
75 IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_reaction_target_exactly_one') THEN
76 ALTER TABLE issue_reaction
77 ADD CONSTRAINT issue_reaction_target_exactly_one
78 CHECK (((issue_id IS NOT NULL)::int + (comment_id IS NOT NULL)::int + (project_id IS NOT NULL)::int) = 1);
79 END IF;
80END $$;
81
82DO $$ BEGIN
83 IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_watch_target_xor') THEN
84 ALTER TABLE issue_watch
85 ADD CONSTRAINT issue_watch_target_xor
86 CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL));
87 END IF;
88END $$;
89
90DO $$ BEGIN
91 IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_event_target_xor') THEN
92 ALTER TABLE issue_event_line
93 ADD CONSTRAINT issue_event_target_xor
94 CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL));
95 END IF;
96END $$;
97
98DO $$ BEGIN
99 IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_pin_target_xor') THEN
100 ALTER TABLE issue_pin
101 ADD CONSTRAINT issue_pin_target_xor
102 CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL));
103 END IF;
104END $$;
105
106DO $$ BEGIN
107 IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'project_links_not_self') THEN
108 ALTER TABLE project_links
109 ADD CONSTRAINT project_links_not_self CHECK (project_id <> related_project_id);
110 END IF;
111END $$;
No newline at end of file
Modified Cargo.lock Cargo.lock +10 −0 View Cargo.lock
@@ -3806,6 +3806,15 @@
38063806]
38073807
38083808[[package]]
3809name = "inventory"
3810version = "0.3.24"
3811source = "registry+https://github.com/rust-lang/crates.io-index"
3812checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
3813dependencies = [
3814 "rustversion",
3815]
3816
3817[[package]]
38093818name = "io-close"
38103819version = "0.3.7"
38113820source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6030,6 +6039,7 @@
60306039 "derive-where",
60316040 "derive_more",
60326041 "futures-util",
6042 "inventory",
60336043 "itertools 0.14.0",
60346044 "log",
60356045 "mac_address",