diff --git a/Cargo.lock b/Cargo.lock index 83ce8b63..d17a6e9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4180,13 +4180,34 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros", + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -4464,7 +4485,7 @@ dependencies = [ "icu_segmenter", "itertools 0.14.0", "memchr", - "strum", + "strum 0.28.0", "thiserror 2.0.20", ] @@ -4908,6 +4929,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "strum 0.27.2", "thiserror 2.0.20", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 36502a06..890444ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,8 @@ dotenvy = "0.15" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } flate2 = "1" +# 枚举的字符串化与遍历(来源种类 `SourceKind`)——手写两份清单的漂移就是 #247 +strum = { version = "0.27", features = ["derive"] } # Phase 1: 摄入/检索/LLM utopia-ingest = { path = "crates/utopia-ingest" } diff --git a/crates/utopia-core/Cargo.toml b/crates/utopia-core/Cargo.toml index 7075cfd8..2d2e8e10 100644 --- a/crates/utopia-core/Cargo.toml +++ b/crates/utopia-core/Cargo.toml @@ -14,3 +14,4 @@ chrono.workspace = true sqlx.workspace = true figment.workspace = true pgvector.workspace = true +strum.workspace = true diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index 4085d6ae..63b195c2 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -165,6 +165,67 @@ impl Source { } } +/// 来源的种类。**一处定义,三处消费**:创建时的白名单、同步时的分派(按枚举穷举匹配, +/// 加一种就得决定它怎么同步)、前端的下拉框(`web/src/sourceKinds.ts`,由 +/// `utopia-store` 的测试对表)。 +/// +/// 此前后端两张手写清单各自演进:五种连接器加了同步分支、进了界面,却没进创建的 +/// 白名单,界面上选得到、建的时候报「kind must be one of…」(#247)。变体顺序就是 +/// 对话框里的顺序;字符串形式由 strum 按 snake_case 生成,不再手写 +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + strum::EnumIter, + strum::IntoStaticStr, + strum::EnumString, +)] +#[strum(serialize_all = "snake_case")] +pub enum SourceKind { + Folder, + Url, + Rss, + GithubIssues, + JiraIssues, + S3, + AzureBlob, + Gcs, + Webdav, + Notion, + Api, + Custom, + /// 每个库自带的记忆来源,不可建不可删(0015) + Memory, + /// 老数据里 `sources.kind` 的默认值,没有对应的界面 + Upload, +} + +impl SourceKind { + pub fn as_str(self) -> &'static str { + self.into() + } + + pub fn parse(s: &str) -> Option { + s.parse().ok() + } + + pub fn all() -> impl Iterator { + ::iter() + } + + /// 人能从界面建的:`memory` 与 `upload` 之外的全部 + pub fn creatable_by_hand(self) -> bool { + !matches!(self, Self::Memory | Self::Upload) + } + + pub fn creatable() -> impl Iterator { + Self::all().filter(|k| k.creatable_by_hand()) + } +} + /// 来源同步运行记录(渠道审计历史)。 #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct SyncRun { diff --git a/crates/utopia-server/src/ingest_sources.rs b/crates/utopia-server/src/ingest_sources.rs index 56afd33c..a1803d48 100644 --- a/crates/utopia-server/src/ingest_sources.rs +++ b/crates/utopia-server/src/ingest_sources.rs @@ -8,6 +8,7 @@ use crate::state::AppState; use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; use utopia_core::models::Source; +use utopia_core::models::SourceKind; use uuid::Uuid; /// 单次同步的新文档上限(防超长 feed/URL 列表拖垮任务) @@ -48,17 +49,23 @@ pub async fn sync_source(state: &AppState, source_id: Uuid) -> anyhow::Result<() let run_id = utopia_store::sources::start_run(&state.pool, source_id).await?; state.emit_source(source.kb_id); - let outcome = match source.kind.as_str() { - "url" => sync_urls(state, &source).await, - "rss" => sync_rss(state, &source).await, - "custom" => sync_custom(state, &source).await, - "github_issues" => sync_github_issues(state, &source).await, - "jira_issues" => sync_jira_issues(state, &source).await, - "s3" | "azure_blob" | "gcs" => sync_object_storage(state, &source).await, - "webdav" => sync_webdav(state, &source).await, - "notion" => sync_notion(state, &source).await, - // folder / api 无拉取语义 - _ => Ok(SyncStats::default()), + // 按枚举穷举:加一种来源就得在这里决定它怎么同步,编译器不放过漏掉的那一支 + let outcome = match SourceKind::parse(&source.kind) { + Some(SourceKind::Url) => sync_urls(state, &source).await, + Some(SourceKind::Rss) => sync_rss(state, &source).await, + Some(SourceKind::Custom) => sync_custom(state, &source).await, + Some(SourceKind::GithubIssues) => sync_github_issues(state, &source).await, + Some(SourceKind::JiraIssues) => sync_jira_issues(state, &source).await, + Some(SourceKind::S3 | SourceKind::AzureBlob | SourceKind::Gcs) => { + sync_object_storage(state, &source).await + } + Some(SourceKind::Webdav) => sync_webdav(state, &source).await, + Some(SourceKind::Notion) => sync_notion(state, &source).await, + // 被动容器:folder / api / memory / upload 没有拉取语义 + Some(SourceKind::Folder | SourceKind::Api | SourceKind::Memory | SourceKind::Upload) => { + Ok(SyncStats::default()) + } + None => Err(anyhow::anyhow!("unknown source kind `{}`", source.kind)), }; match outcome { diff --git a/crates/utopia-store/src/sources.rs b/crates/utopia-store/src/sources.rs index e4d6f381..ab7add38 100644 --- a/crates/utopia-store/src/sources.rs +++ b/crates/utopia-store/src/sources.rs @@ -2,28 +2,22 @@ use chrono::{DateTime, Utc}; use sqlx::PgPool; -use utopia_core::models::{Role, Source, SourceView, SyncRun, SOURCE_SECRET_KEYS}; +use utopia_core::models::{Role, Source, SourceKind, SourceView, SyncRun, SOURCE_SECRET_KEYS}; use utopia_core::{AppError, AppResult}; use uuid::Uuid; /// folder = 纯容器(上传/拖拽入内,无同步语义);url/rss = 拉取型;api = 推送型。 /// 本机目录监听(watch_folder)已否决——自部署用户看不到服务器磁盘; -/// 未来的 watch 形态是对象存储/网盘(P5 连接器,与 BlobStore 接缝配套)。 +/// 对象存储 / WebDAV / Notion 是它的替代形态(0013)。 /// custom = 自定义拉取器:任何实现 Utopia ingest 接口的 URL(返回 items JSON)即可定时摄取。 /// github_issues / jira_issues = 工单:一张工单连同它的状态变更史成为一篇文档。 /// -/// **改这里就得改前端那份清单**(`Library.tsx` 的建来源对话框与 `api.ts` 的 -/// `SourceView["kind"]`)。两处对不上时的症状是:界面上选得到、建的时候报 -/// 「kind must be one of…」——单元测试与 tsc 都看不见,只有端到端会撞上。 -pub const KINDS: &[&str] = &[ - "folder", - "url", - "rss", - "api", - "custom", - "github_issues", - "jira_issues", -]; +/// 种类的清单**不在这里写**:`SourceKind`(utopia-core)一个枚举出全部——创建的白名单、 +/// 同步的分派、前端的下拉框(有测试对表)。从前这里有一张手写的 `KINDS`,五种连接器 +/// 加了同步却没进这张表,界面上选得到、建不出来(#247) +pub fn creatable_kinds() -> Vec<&'static str> { + SourceKind::creatable().map(|k| k.as_str()).collect() +} /// 校验并规范化标准 5 段 cron 表达式(内部用 cron crate 的 6 段:补秒位)。 pub fn validate_cron(expr: &str) -> AppResult { @@ -93,10 +87,10 @@ pub async fn create( sync_interval_minutes: Option, sync_cron: Option<&str>, ) -> AppResult { - if !KINDS.contains(&kind) { + if !SourceKind::parse(kind).is_some_and(|k| k.creatable_by_hand()) { return Err(AppError::Validation(format!( "kind must be one of: {}", - KINDS.join(", ") + creatable_kinds().join(", ") ))); } if name.trim().is_empty() { diff --git a/crates/utopia-store/tests/a_source_kind_is_listed_once.rs b/crates/utopia-store/tests/a_source_kind_is_listed_once.rs new file mode 100644 index 00000000..4e616411 --- /dev/null +++ b/crates/utopia-store/tests/a_source_kind_is_listed_once.rs @@ -0,0 +1,53 @@ +//! #247:来源的种类只在一处定义,前后端对表。 +//! +//! 后端 `SourceKind`(utopia-core)一个枚举出两份清单:创建时的白名单、同步时的分派 +//! (后者按枚举穷举匹配,编译器保证加了种类就得决定它怎么同步)。前端那一份在 +//! `web/src/sourceKinds.ts`,这个测试把它读出来跟枚举比——此前两边各自手写,五种 +//! 连接器进了界面、进了同步,却没进创建白名单,界面上选得到、建的时候报 +//! 「kind must be one of…」。单元测试与 tsc 都看不见的那种漂移,这里看得见。 +//! +//! 不需要数据库。 + +use std::path::Path; +use utopia_core::models::SourceKind; + +/// 从 `CREATABLE_SOURCE_KINDS = [ "…", … ] as const` 里把引号里的字面量按顺序读出来 +fn frontend_kinds(src: &str) -> Vec { + let start = src + .find("CREATABLE_SOURCE_KINDS = [") + .expect("web/src/sourceKinds.ts declares CREATABLE_SOURCE_KINDS"); + let body = &src[start..]; + let end = body.find(']').expect("the array closes"); + body[..end] + .split('"') + .skip(1) + .step_by(2) + .map(str::to_string) + .collect() +} + +#[test] +fn the_frontend_list_matches_the_backend_enum() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/src/sourceKinds.ts"); + let src = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let frontend = frontend_kinds(&src); + let backend: Vec = SourceKind::creatable() + .map(|k| k.as_str().to_string()) + .collect(); + assert_eq!( + frontend, backend, + "web/src/sourceKinds.ts and utopia_core::models::SourceKind list different kinds (order matters: it is the dialog's order)" + ); +} + +#[test] +fn every_kind_round_trips_through_its_string() { + for k in SourceKind::all() { + assert_eq!(SourceKind::parse(k.as_str()), Some(k), "{k:?}"); + } + assert_eq!(SourceKind::parse("watch_folder"), None); + assert!(!SourceKind::Memory.creatable_by_hand()); + assert!(!SourceKind::Upload.creatable_by_hand()); + assert!(SourceKind::S3.creatable_by_hand()); +} diff --git a/docs/decisions/0013-a-source-should-hand-over-its-history.md b/docs/decisions/0013-a-source-should-hand-over-its-history.md index c84d1f66..ef7f42ce 100644 --- a/docs/decisions/0013-a-source-should-hand-over-its-history.md +++ b/docs/decisions/0013-a-source-should-hand-over-its-history.md @@ -111,3 +111,4 @@ shape — a class with an IRI drawn as a circle is a picture that lies. ## Revisions - 2026-09-03: every connector's credentials stay on the server (#246). Until now only `auth_header` was stripped from responses; the object-storage, WebDAV and Notion keys went out to every viewer. The keys now live in one list, `SOURCE_SECRET_KEYS`, shared by the listing, the create / update responses and the update merge (blank or missing keeps the stored value, an explicit `null` removes it). Adding a connector means adding its keys there first. +- 2026-09-03: the five connectors added under this record could not be created (#247): the store's hand-written `KINDS` allowlist stopped at seven kinds while the sync dispatcher and the UI knew twelve. The kinds now come from one enum, `SourceKind` in `utopia-core`; the allowlist is derived from it, the dispatcher matches it exhaustively, and a test compares the frontend's `web/src/sourceKinds.ts` against it, so the three can no longer drift apart. diff --git a/web/src/api.ts b/web/src/api.ts index c2775ed0..1ed58609 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,3 +1,4 @@ +import type { SourceKind } from "./sourceKinds"; import { S, lang } from "./i18n"; export class ApiError extends Error { @@ -179,21 +180,7 @@ export interface ExtractionDrop { export interface SourceView { id: string; - kind: - | "folder" - | "url" - | "rss" - | "api" - | "custom" - | "github_issues" - | "jira_issues" - | "s3" - | "azure_blob" - | "gcs" - | "webdav" - | "notion" - | "memory" - | "upload"; + kind: SourceKind; name: string; config: { urls?: string[]; diff --git a/web/src/pages/Library.tsx b/web/src/pages/Library.tsx index e5764ca8..5a379737 100644 --- a/web/src/pages/Library.tsx +++ b/web/src/pages/Library.tsx @@ -14,6 +14,10 @@ import { } from "lucide-react"; import { api, type Doc, type ExtractionDrop, type SourceView } from "../api"; import { S } from "../i18n"; +import { + CREATABLE_SOURCE_KINDS, + type CreatableSourceKind, +} from "../sourceKinds"; import { useKb, useKbId } from "../kb"; import { toast } from "../toast"; import { Chip, type ChipTone, DangerConfirm, Loading, Pager } from "../ui"; @@ -1267,20 +1271,7 @@ function SourceModal({ /** isApi=true 时父级紧接着打开密钥弹窗 */ onDone: (id?: string, isApi?: boolean) => void; }) { - const [kind, setKind] = useState< - | "folder" - | "url" - | "rss" - | "custom" - | "api" - | "github_issues" - | "jira_issues" - | "s3" - | "azure_blob" - | "gcs" - | "webdav" - | "notion" - >("folder"); + const [kind, setKind] = useState("folder"); const [name, setName] = useState(""); const [icon, setIcon] = useState(null); const [urls, setUrls] = useState(""); @@ -1447,22 +1438,7 @@ function SourceModal({
{/* 类型 */}
- {( - [ - "folder", - "url", - "rss", - "github_issues", - "jira_issues", - "s3", - "azure_blob", - "gcs", - "webdav", - "notion", - "api", - "custom", - ] as const - ).map((k) => { + {CREATABLE_SOURCE_KINDS.map((k) => { const Icon = KIND_ICON[k]; return (