feat: add paginated project discovery

This commit is contained in:
2026-07-12 01:45:15 +08:00
parent 53d372e29b
commit 816af1abdb
14 changed files with 1505 additions and 237 deletions
@@ -0,0 +1,191 @@
-- Derived project discovery projection. Project/Folder remain authoritative;
-- triggers prevent index drift through ordinary database mutations.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TYPE "FolderKind" AS ENUM ('REGULAR', 'SYSTEM_INBOX');
ALTER TABLE "Folder" ADD COLUMN "kind" "FolderKind" NOT NULL DEFAULT 'REGULAR';
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "Folder"
WHERE "parentId" IS NULL AND "name" = 'Inbox' AND "archivedAt" IS NULL
GROUP BY "organizationId" HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'cannot identify system Inbox: organization has multiple active root Inbox folders';
END IF;
END $$;
UPDATE "Folder" f SET "kind" = 'SYSTEM_INBOX'
WHERE f."parentId" IS NULL AND f."name" = 'Inbox' AND f."archivedAt" IS NULL;
INSERT INTO "Folder" ("id", "organizationId", "parentId", "name", "kind", "sortKey", "createdAt", "updatedAt")
SELECT o."id" || ':system-inbox', o."id", NULL, 'Inbox', 'SYSTEM_INBOX', '000000', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "Organization" o
WHERE NOT EXISTS (
SELECT 1 FROM "Folder" f
WHERE f."organizationId" = o."id" AND f."kind" = 'SYSTEM_INBOX' AND f."archivedAt" IS NULL
);
CREATE UNIQUE INDEX "Folder_one_active_system_inbox_per_org"
ON "Folder"("organizationId") WHERE "kind" = 'SYSTEM_INBOX' AND "archivedAt" IS NULL;
CREATE OR REPLACE FUNCTION cph_protect_system_inbox() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' AND OLD."kind" = 'SYSTEM_INBOX' AND (
NEW."id" IS DISTINCT FROM OLD."id" OR
NEW."organizationId" IS DISTINCT FROM OLD."organizationId" OR
NEW."kind" IS DISTINCT FROM OLD."kind" OR
NEW."name" IS DISTINCT FROM OLD."name" OR
NEW."parentId" IS DISTINCT FROM OLD."parentId" OR
NEW."archivedAt" IS DISTINCT FROM OLD."archivedAt"
) THEN
RAISE EXCEPTION 'system Inbox identity cannot be changed';
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW."kind" = 'SYSTEM_INBOX' AND (
NEW."name" <> 'Inbox' OR NEW."parentId" IS NOT NULL OR NEW."archivedAt" IS NOT NULL
) THEN
RAISE EXCEPTION 'system Inbox must be an active root folder named Inbox';
END IF;
IF TG_OP = 'DELETE' AND OLD."kind" = 'SYSTEM_INBOX' AND EXISTS (
SELECT 1 FROM "Organization" WHERE "id" = OLD."organizationId"
) THEN
RAISE EXCEPTION 'system Inbox cannot be deleted while its organization exists';
END IF;
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
END;
$$;
CREATE TRIGGER cph_protect_system_inbox
BEFORE INSERT OR UPDATE OR DELETE ON "Folder"
FOR EACH ROW EXECUTE FUNCTION cph_protect_system_inbox();
ALTER TABLE "Project" ADD COLUMN "code" TEXT;
CREATE TABLE "ProjectSearchDocument" (
"projectId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT,
"normalizedCode" TEXT NOT NULL,
"normalizedName" TEXT NOT NULL,
"breadcrumb" TEXT NOT NULL,
"normalizedBreadcrumb" TEXT NOT NULL,
"normalizedSearchText" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProjectSearchDocument_pkey" PRIMARY KEY ("projectId")
);
CREATE INDEX "ProjectSearchDocument_organizationId_idx" ON "ProjectSearchDocument"("organizationId");
CREATE INDEX "ProjectSearchDocument_normalizedName_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedName" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedCode_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedCode" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedBreadcrumb_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedBreadcrumb" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedSearchText_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedSearchText" gin_trgm_ops);
ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE OR REPLACE FUNCTION cph_project_search_normalize(value TEXT) RETURNS TEXT
LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$
SELECT lower(regexp_replace(normalize(value, NFKC), '[[:space:]_.:/\\-]+', '', 'g'))
$$;
CREATE OR REPLACE FUNCTION cph_folder_breadcrumb(folder_id TEXT) RETURNS TEXT
LANGUAGE sql STABLE PARALLEL SAFE AS $$
WITH RECURSIVE ancestors AS (
SELECT f."id", f."parentId", f."name", f."kind", 0 AS depth
FROM "Folder" f
WHERE f."id" = folder_id
UNION ALL
SELECT parent."id", parent."parentId", parent."name", parent."kind", child.depth + 1
FROM "Folder" parent
JOIN ancestors child ON parent."id" = child."parentId"
)
SELECT CASE
WHEN count(*) = 1 AND bool_and("kind" = 'SYSTEM_INBOX') THEN '未分类'
ELSE coalesce(string_agg("name", ' / ' ORDER BY depth DESC), '')
END
FROM ancestors
$$;
CREATE OR REPLACE FUNCTION cph_project_search_code(explicit_code TEXT, project_name TEXT) RETURNS TEXT
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT CASE
WHEN explicit_code IS NOT NULL AND btrim(explicit_code) <> ''
THEN cph_project_search_normalize(explicit_code)
ELSE coalesce(substring(cph_project_search_normalize(project_name) FROM '^[a-z]+[0-9]+'), '')
END
$$;
CREATE OR REPLACE FUNCTION cph_refresh_project_search_document(target_project_id TEXT) RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO "ProjectSearchDocument" (
"projectId", "organizationId", "name", "code", "normalizedCode", "normalizedName",
"breadcrumb", "normalizedBreadcrumb", "normalizedSearchText", "updatedAt"
)
SELECT p."id", p."organizationId", p."name", p."code",
cph_project_search_code(p."code", p."name"),
cph_project_search_normalize(p."name"),
cph_folder_breadcrumb(p."folderId"),
cph_project_search_normalize(cph_folder_breadcrumb(p."folderId")),
cph_project_search_normalize(cph_folder_breadcrumb(p."folderId") || ' ' || p."name"),
CURRENT_TIMESTAMP
FROM "Project" p WHERE p."id" = target_project_id
ON CONFLICT ("projectId") DO UPDATE SET
"organizationId" = EXCLUDED."organizationId",
"name" = EXCLUDED."name",
"code" = EXCLUDED."code",
"normalizedCode" = EXCLUDED."normalizedCode",
"normalizedName" = EXCLUDED."normalizedName",
"breadcrumb" = EXCLUDED."breadcrumb",
"normalizedBreadcrumb" = EXCLUDED."normalizedBreadcrumb",
"normalizedSearchText" = EXCLUDED."normalizedSearchText",
"updatedAt" = CURRENT_TIMESTAMP;
END;
$$;
CREATE OR REPLACE FUNCTION cph_project_search_project_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM cph_refresh_project_search_document(NEW."id");
RETURN NEW;
END;
$$;
CREATE TRIGGER cph_project_search_project_changed
AFTER INSERT OR UPDATE OF "name", "code", "folderId", "organizationId", "archivedAt" ON "Project"
FOR EACH ROW EXECUTE FUNCTION cph_project_search_project_trigger();
CREATE OR REPLACE FUNCTION cph_project_search_folder_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE project_id TEXT;
BEGIN
FOR project_id IN
WITH RECURSIVE descendants AS (
SELECT NEW."id"
UNION ALL
SELECT child."id" FROM "Folder" child
JOIN descendants parent ON child."parentId" = parent."id"
)
SELECT p."id" FROM "Project" p
WHERE p."folderId" IN (SELECT "id" FROM descendants)
LOOP
PERFORM cph_refresh_project_search_document(project_id);
END LOOP;
RETURN NEW;
END;
$$;
CREATE TRIGGER cph_project_search_folder_changed
AFTER UPDATE OF "name", "kind", "parentId", "archivedAt" ON "Folder"
FOR EACH ROW EXECUTE FUNCTION cph_project_search_folder_trigger();
SELECT cph_refresh_project_search_document("id") FROM "Project";
+30
View File
@@ -46,6 +46,7 @@ model Organization {
agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[]
auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
@@index([status])
}
@@ -437,11 +438,17 @@ model ExternalPrincipalMembership {
/// ADR-0021: transparent project explorer folder. Folders are org-scoped
/// navigation/aggregation nodes, not permission resources; project grants stay
/// attached to PROJECT resources.
enum FolderKind {
REGULAR
SYSTEM_INBOX
}
model Folder {
id String @id @default(cuid())
organizationId String
parentId String?
name String
kind FolderKind @default(REGULAR)
sortKey String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -460,6 +467,7 @@ model Project {
id String @id @default(cuid())
organizationId String
folderId String?
code String?
name String
workspaceDir String
createdByUserId String?
@@ -477,12 +485,34 @@ model Project {
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
searchDocument ProjectSearchDocument?
@@index([organizationId, archivedAt])
@@index([folderId, archivedAt])
@@index([archivedAt])
}
/// Derived, rebuildable search projection for project discovery. PostgreSQL
/// triggers keep it synchronized with Project and Folder mutations; Project
/// remains the source of truth and authorization remains outside this table.
model ProjectSearchDocument {
projectId String @id
organizationId String
name String
code String?
normalizedCode String
normalizedName String
breadcrumb String
normalizedBreadcrumb String
normalizedSearchText String
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId])
}
/// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat
/// (1:1). Historical archived bindings are retained for audit; partial unique
/// indexes in migrations enforce one active binding per project and per chat.