From 2acc53705857aa667cc63404fa8f380e0e2ed35a Mon Sep 17 00:00:00 2001 From: Kuroda Kayn Date: Sun, 28 Jun 2026 22:53:07 +0800 Subject: [PATCH 1/5] refactor(models): move lifecycle hooks out of model definitions The core models file mixed type declarations with persistence lifecycle behavior, making later model changes harder to review. Move the existing non-AI BeforeCreate hooks into a dedicated hooks file without changing their behavior. This leaves models.go focused on declarations while preserving the same GORM lifecycle defaults. --- backend/internal/models/hooks.go | 310 ++++++++++++++++++++++++++++++ backend/internal/models/models.go | 302 ----------------------------- 2 files changed, 310 insertions(+), 302 deletions(-) create mode 100644 backend/internal/models/hooks.go diff --git a/backend/internal/models/hooks.go b/backend/internal/models/hooks.go new file mode 100644 index 00000000..7cc6d9f7 --- /dev/null +++ b/backend/internal/models/hooks.go @@ -0,0 +1,310 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +func (u *User) BeforeCreate(_ *gorm.DB) (err error) { + if u.ID == uuid.Nil { + u.ID = uuid.New() + } + return +} + +func (p *Project) BeforeCreate(_ *gorm.DB) (err error) { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + return +} + +func (m *MediaAsset) BeforeCreate(_ *gorm.DB) (err error) { + if m.ID == uuid.Nil { + m.ID = uuid.New() + } + if m.Status == "" { + m.Status = MediaAssetStatusPending + } + if m.LibraryScope == "" { + m.LibraryScope = MediaAssetLibraryScopeProject + } + if m.Tags == nil { + m.Tags = datatypes.JSON([]byte(`[]`)) + } + return +} + +func (t *ContentTemplate) BeforeCreate(_ *gorm.DB) (err error) { + if t.ID == uuid.Nil { + t.ID = uuid.New() + } + if t.Scope == "" { + t.Scope = ContentTemplateScopePersonal + } + if t.DefaultPlatforms == nil { + t.DefaultPlatforms = datatypes.JSON([]byte(`[]`)) + } + if t.PlatformConfig == nil { + t.PlatformConfig = datatypes.JSON([]byte(`{}`)) + } + if t.Tags == nil { + t.Tags = datatypes.JSON([]byte(`[]`)) + } + return +} + +func (b *BrandProfile) BeforeCreate(_ *gorm.DB) (err error) { + if b.ID == uuid.Nil { + b.ID = uuid.New() + } + if b.BannedWords == nil { + b.BannedWords = datatypes.JSON([]byte(`[]`)) + } + if b.DefaultTags == nil { + b.DefaultTags = datatypes.JSON([]byte(`[]`)) + } + return +} + +func (u *MediaAssetUsage) BeforeCreate(_ *gorm.DB) (err error) { + if u.ID == uuid.Nil { + u.ID = uuid.New() + } + return +} + +func (p *ProjectPlatformPublication) BeforeCreate(tx *gorm.DB) (err error) { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + if p.WorkspaceID == uuid.Nil { + p.WorkspaceID = deriveWorkspaceIDFromProject(tx, p.ProjectID, uuid.Nil) + } + if p.DraftStatus == "" { + p.DraftStatus = PublicationDraftStatusUnsynced + } + if p.ReviewStatus == "" { + p.ReviewStatus = PublicationReviewStatusDraft + } + return +} + +func (e *PublishEvent) BeforeCreate(tx *gorm.DB) (err error) { + if e.ID == uuid.Nil { + e.ID = uuid.New() + } + if e.WorkspaceID == uuid.Nil { + e.WorkspaceID = deriveWorkspaceIDFromProject(tx, e.ProjectID, e.UserID) + } + if e.CreatedAt.IsZero() { + e.CreatedAt = time.Now().UTC() + } + return +} + +func (s *ScheduledPublication) BeforeCreate(_ *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + if s.Status == "" { + s.Status = ScheduledPublicationStatusScheduled + } + if s.Timezone == "" { + s.Timezone = "UTC" + } + return +} + +func (a *PublishAttempt) BeforeCreate(_ *gorm.DB) (err error) { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + if a.Status == "" { + a.Status = PublishAttemptStatusRunning + } + return +} + +func (e *OutboxEvent) BeforeCreate(_ *gorm.DB) (err error) { + if e.ID == uuid.Nil { + e.ID = uuid.New() + } + if e.Payload == nil { + e.Payload = datatypes.JSON([]byte(`{}`)) + } + if e.Status == "" { + e.Status = OutboxStatusPending + } + return +} + +func (w *Workspace) BeforeCreate(_ *gorm.DB) (err error) { + if w.ID == uuid.Nil { + w.ID = uuid.New() + } + if w.Status == "" { + w.Status = WorkspaceStatusActive + } + return +} + +func (a *WorkspaceActivity) BeforeCreate(_ *gorm.DB) (err error) { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now().UTC() + } + return +} + +func (n *Notification) BeforeCreate(_ *gorm.DB) (err error) { + if n.ID == uuid.Nil { + n.ID = uuid.New() + } + if n.Status == "" { + n.Status = NotificationStatusUnread + } + return +} + +func (i *WorkspaceInvite) BeforeCreate(_ *gorm.DB) (err error) { + if i.ID == uuid.Nil { + i.ID = uuid.New() + } + if i.Status == "" { + i.Status = WorkspaceInviteStatusPending + } + return +} + +func (a *ProjectActivity) BeforeCreate(tx *gorm.DB) (err error) { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + if a.WorkspaceID == uuid.Nil { + a.WorkspaceID = deriveWorkspaceIDFromProject(tx, a.ProjectID, a.ActorUserID) + } + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now().UTC() + } + return +} + +func (c *ProjectComment) BeforeCreate(tx *gorm.DB) (err error) { + if c.ID == uuid.Nil { + c.ID = uuid.New() + } + if c.WorkspaceID == uuid.Nil { + c.WorkspaceID = deriveWorkspaceIDFromProject(tx, c.ProjectID, c.AuthorID) + } + if c.Status == "" { + c.Status = ProjectCommentStatusOpen + } + return +} + +func (v *ProjectVersion) BeforeCreate(tx *gorm.DB) (err error) { + if v.ID == uuid.Nil { + v.ID = uuid.New() + } + if v.WorkspaceID == uuid.Nil { + v.WorkspaceID = deriveWorkspaceIDFromProject(tx, v.ProjectID, v.CreatedBy) + } + return +} + +func (l *ProjectShareLink) BeforeCreate(tx *gorm.DB) (err error) { + if l.ID == uuid.Nil { + l.ID = uuid.New() + } + if l.WorkspaceID == uuid.Nil { + l.WorkspaceID = deriveWorkspaceIDFromProject(tx, l.ProjectID, l.CreatedBy) + } + if l.Status == "" { + l.Status = ProjectShareLinkStatusActive + } + return +} + +func (pa *PlatformAccount) BeforeCreate(_ *gorm.DB) (err error) { + if pa.ID == uuid.Nil { + pa.ID = uuid.New() + } + if pa.OwnerUserID == nil && pa.UserID != uuid.Nil { + ownerID := pa.UserID + pa.OwnerUserID = &ownerID + } + if pa.ConnectedByUserID == nil && pa.UserID != uuid.Nil { + connectedBy := pa.UserID + pa.ConnectedByUserID = &connectedBy + } + if pa.WorkspaceID == nil && pa.UserID != uuid.Nil { + workspaceID := PersonalWorkspaceID(pa.UserID) + pa.WorkspaceID = &workspaceID + } + if pa.DisplayName == "" { + pa.DisplayName = pa.Username + } + if pa.ShareScope == "" { + pa.ShareScope = PlatformAccountSharePrivate + } + if pa.HealthStatus == "" { + pa.HealthStatus = PlatformAccountHealthUnknown + } + if pa.CredentialSecretRef == "" { + pa.CredentialSecretRef = "platform-account:" + pa.ID.String() + } + return +} + +func (s *RemoteBrowserSession) BeforeCreate(_ *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + if s.CreatedAt.IsZero() { + s.CreatedAt = time.Now().UTC() + } + return +} + +func (g *PlatformAccountGrant) BeforeCreate(_ *gorm.DB) (err error) { + if g.ID == uuid.Nil { + g.ID = uuid.New() + } + return +} + +func (t *ExtensionCallbackToken) BeforeCreate(tx *gorm.DB) (err error) { + if t.ID == uuid.Nil { + t.ID = uuid.New() + } + if t.WorkspaceID == uuid.Nil { + t.WorkspaceID = deriveWorkspaceIDFromProject(tx, t.ProjectID, t.UserID) + } + return +} + +func (e *ExtensionExecutionEvent) BeforeCreate(tx *gorm.DB) (err error) { + if e.ID == uuid.Nil { + e.ID = uuid.New() + } + if e.WorkspaceID == uuid.Nil { + e.WorkspaceID = deriveWorkspaceIDFromProject(tx, e.ProjectID, e.UserID) + } + if e.CreatedAt.IsZero() { + e.CreatedAt = time.Now().UTC() + } + return +} + +func (c *ExtensionExecutionEventClaim) BeforeCreate(_ *gorm.DB) (err error) { + if c.CreatedAt.IsZero() { + c.CreatedAt = time.Now().UTC() + } + return +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index c852da26..c41b84a0 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -683,305 +683,3 @@ type ExtensionExecutionEventClaim struct { RecordID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex"` CreatedAt time.Time `gorm:"not null;index"` } - -// BeforeCreate hook to generate UUID if not set -func (u *User) BeforeCreate(_ *gorm.DB) (err error) { - if u.ID == uuid.Nil { - u.ID = uuid.New() - } - return -} - -func (p *Project) BeforeCreate(_ *gorm.DB) (err error) { - if p.ID == uuid.Nil { - p.ID = uuid.New() - } - return -} - -func (m *MediaAsset) BeforeCreate(_ *gorm.DB) (err error) { - if m.ID == uuid.Nil { - m.ID = uuid.New() - } - if m.Status == "" { - m.Status = MediaAssetStatusPending - } - if m.LibraryScope == "" { - m.LibraryScope = MediaAssetLibraryScopeProject - } - if m.Tags == nil { - m.Tags = datatypes.JSON([]byte(`[]`)) - } - return -} - -func (t *ContentTemplate) BeforeCreate(_ *gorm.DB) (err error) { - if t.ID == uuid.Nil { - t.ID = uuid.New() - } - if t.Scope == "" { - t.Scope = ContentTemplateScopePersonal - } - if t.DefaultPlatforms == nil { - t.DefaultPlatforms = datatypes.JSON([]byte(`[]`)) - } - if t.PlatformConfig == nil { - t.PlatformConfig = datatypes.JSON([]byte(`{}`)) - } - if t.Tags == nil { - t.Tags = datatypes.JSON([]byte(`[]`)) - } - return -} - -func (b *BrandProfile) BeforeCreate(_ *gorm.DB) (err error) { - if b.ID == uuid.Nil { - b.ID = uuid.New() - } - if b.BannedWords == nil { - b.BannedWords = datatypes.JSON([]byte(`[]`)) - } - if b.DefaultTags == nil { - b.DefaultTags = datatypes.JSON([]byte(`[]`)) - } - return -} - -func (u *MediaAssetUsage) BeforeCreate(_ *gorm.DB) (err error) { - if u.ID == uuid.Nil { - u.ID = uuid.New() - } - return -} - -func (p *ProjectPlatformPublication) BeforeCreate(tx *gorm.DB) (err error) { - if p.ID == uuid.Nil { - p.ID = uuid.New() - } - if p.WorkspaceID == uuid.Nil { - p.WorkspaceID = deriveWorkspaceIDFromProject(tx, p.ProjectID, uuid.Nil) - } - if p.DraftStatus == "" { - p.DraftStatus = PublicationDraftStatusUnsynced - } - if p.ReviewStatus == "" { - p.ReviewStatus = PublicationReviewStatusDraft - } - return -} - -func (e *PublishEvent) BeforeCreate(tx *gorm.DB) (err error) { - if e.ID == uuid.Nil { - e.ID = uuid.New() - } - if e.WorkspaceID == uuid.Nil { - e.WorkspaceID = deriveWorkspaceIDFromProject(tx, e.ProjectID, e.UserID) - } - if e.CreatedAt.IsZero() { - e.CreatedAt = time.Now().UTC() - } - return -} - -func (s *ScheduledPublication) BeforeCreate(_ *gorm.DB) (err error) { - if s.ID == uuid.Nil { - s.ID = uuid.New() - } - if s.Status == "" { - s.Status = ScheduledPublicationStatusScheduled - } - if s.Timezone == "" { - s.Timezone = "UTC" - } - return -} - -func (a *PublishAttempt) BeforeCreate(_ *gorm.DB) (err error) { - if a.ID == uuid.Nil { - a.ID = uuid.New() - } - if a.Status == "" { - a.Status = PublishAttemptStatusRunning - } - return -} - -func (e *OutboxEvent) BeforeCreate(_ *gorm.DB) (err error) { - if e.ID == uuid.Nil { - e.ID = uuid.New() - } - if e.Payload == nil { - e.Payload = datatypes.JSON([]byte(`{}`)) - } - if e.Status == "" { - e.Status = OutboxStatusPending - } - return -} - -func (w *Workspace) BeforeCreate(_ *gorm.DB) (err error) { - if w.ID == uuid.Nil { - w.ID = uuid.New() - } - if w.Status == "" { - w.Status = WorkspaceStatusActive - } - return -} - -func (a *WorkspaceActivity) BeforeCreate(_ *gorm.DB) (err error) { - if a.ID == uuid.Nil { - a.ID = uuid.New() - } - if a.CreatedAt.IsZero() { - a.CreatedAt = time.Now().UTC() - } - return -} - -func (n *Notification) BeforeCreate(_ *gorm.DB) (err error) { - if n.ID == uuid.Nil { - n.ID = uuid.New() - } - if n.Status == "" { - n.Status = NotificationStatusUnread - } - return -} - -func (i *WorkspaceInvite) BeforeCreate(_ *gorm.DB) (err error) { - if i.ID == uuid.Nil { - i.ID = uuid.New() - } - if i.Status == "" { - i.Status = WorkspaceInviteStatusPending - } - return -} - -func (a *ProjectActivity) BeforeCreate(tx *gorm.DB) (err error) { - if a.ID == uuid.Nil { - a.ID = uuid.New() - } - if a.WorkspaceID == uuid.Nil { - a.WorkspaceID = deriveWorkspaceIDFromProject(tx, a.ProjectID, a.ActorUserID) - } - if a.CreatedAt.IsZero() { - a.CreatedAt = time.Now().UTC() - } - return -} - -func (c *ProjectComment) BeforeCreate(tx *gorm.DB) (err error) { - if c.ID == uuid.Nil { - c.ID = uuid.New() - } - if c.WorkspaceID == uuid.Nil { - c.WorkspaceID = deriveWorkspaceIDFromProject(tx, c.ProjectID, c.AuthorID) - } - if c.Status == "" { - c.Status = ProjectCommentStatusOpen - } - return -} - -func (v *ProjectVersion) BeforeCreate(tx *gorm.DB) (err error) { - if v.ID == uuid.Nil { - v.ID = uuid.New() - } - if v.WorkspaceID == uuid.Nil { - v.WorkspaceID = deriveWorkspaceIDFromProject(tx, v.ProjectID, v.CreatedBy) - } - return -} - -func (l *ProjectShareLink) BeforeCreate(tx *gorm.DB) (err error) { - if l.ID == uuid.Nil { - l.ID = uuid.New() - } - if l.WorkspaceID == uuid.Nil { - l.WorkspaceID = deriveWorkspaceIDFromProject(tx, l.ProjectID, l.CreatedBy) - } - if l.Status == "" { - l.Status = ProjectShareLinkStatusActive - } - return -} - -func (pa *PlatformAccount) BeforeCreate(_ *gorm.DB) (err error) { - if pa.ID == uuid.Nil { - pa.ID = uuid.New() - } - if pa.OwnerUserID == nil && pa.UserID != uuid.Nil { - ownerID := pa.UserID - pa.OwnerUserID = &ownerID - } - if pa.ConnectedByUserID == nil && pa.UserID != uuid.Nil { - connectedBy := pa.UserID - pa.ConnectedByUserID = &connectedBy - } - if pa.WorkspaceID == nil && pa.UserID != uuid.Nil { - workspaceID := PersonalWorkspaceID(pa.UserID) - pa.WorkspaceID = &workspaceID - } - if pa.DisplayName == "" { - pa.DisplayName = pa.Username - } - if pa.ShareScope == "" { - pa.ShareScope = PlatformAccountSharePrivate - } - if pa.HealthStatus == "" { - pa.HealthStatus = PlatformAccountHealthUnknown - } - if pa.CredentialSecretRef == "" { - pa.CredentialSecretRef = "platform-account:" + pa.ID.String() - } - return -} - -func (s *RemoteBrowserSession) BeforeCreate(_ *gorm.DB) (err error) { - if s.ID == uuid.Nil { - s.ID = uuid.New() - } - if s.CreatedAt.IsZero() { - s.CreatedAt = time.Now().UTC() - } - return -} - -func (g *PlatformAccountGrant) BeforeCreate(_ *gorm.DB) (err error) { - if g.ID == uuid.Nil { - g.ID = uuid.New() - } - return -} - -func (t *ExtensionCallbackToken) BeforeCreate(tx *gorm.DB) (err error) { - if t.ID == uuid.Nil { - t.ID = uuid.New() - } - if t.WorkspaceID == uuid.Nil { - t.WorkspaceID = deriveWorkspaceIDFromProject(tx, t.ProjectID, t.UserID) - } - return -} - -func (e *ExtensionExecutionEvent) BeforeCreate(tx *gorm.DB) (err error) { - if e.ID == uuid.Nil { - e.ID = uuid.New() - } - if e.WorkspaceID == uuid.Nil { - e.WorkspaceID = deriveWorkspaceIDFromProject(tx, e.ProjectID, e.UserID) - } - if e.CreatedAt.IsZero() { - e.CreatedAt = time.Now().UTC() - } - return -} - -func (c *ExtensionExecutionEventClaim) BeforeCreate(_ *gorm.DB) (err error) { - if c.CreatedAt.IsZero() { - c.CreatedAt = time.Now().UTC() - } - return -} From 4669426b8c7dbc7e23e137bbb556b5a0af5157a5 Mon Sep 17 00:00:00 2001 From: Kuroda Kayn Date: Sun, 28 Jun 2026 22:53:34 +0800 Subject: [PATCH 2/5] feat(database): derive workspace ids for model rows Citus preparation needs non-AI project-domain rows to carry stable tenant routing data even when callers omit it. Derive workspace IDs for media assets, media usages, and scheduled publications from their project or media ownership path. This keeps those rows colocatable by workspace_id without mixing in AI-specific model behavior. --- backend/internal/models/hooks.go | 18 +++++++++-- backend/internal/models/workspace_identity.go | 31 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/backend/internal/models/hooks.go b/backend/internal/models/hooks.go index 7cc6d9f7..cba85602 100644 --- a/backend/internal/models/hooks.go +++ b/backend/internal/models/hooks.go @@ -22,10 +22,16 @@ func (p *Project) BeforeCreate(_ *gorm.DB) (err error) { return } -func (m *MediaAsset) BeforeCreate(_ *gorm.DB) (err error) { +func (m *MediaAsset) BeforeCreate(tx *gorm.DB) (err error) { if m.ID == uuid.Nil { m.ID = uuid.New() } + if m.WorkspaceID == nil { + workspaceID := deriveWorkspaceIDFromMediaAsset(tx, uuid.Nil, m.ProjectID, m.UserID) + if workspaceID != uuid.Nil { + m.WorkspaceID = &workspaceID + } + } if m.Status == "" { m.Status = MediaAssetStatusPending } @@ -70,10 +76,13 @@ func (b *BrandProfile) BeforeCreate(_ *gorm.DB) (err error) { return } -func (u *MediaAssetUsage) BeforeCreate(_ *gorm.DB) (err error) { +func (u *MediaAssetUsage) BeforeCreate(tx *gorm.DB) (err error) { if u.ID == uuid.Nil { u.ID = uuid.New() } + if u.WorkspaceID == uuid.Nil { + u.WorkspaceID = deriveWorkspaceIDFromMediaAsset(tx, u.MediaAssetID, u.ProjectID, uuid.Nil) + } return } @@ -106,10 +115,13 @@ func (e *PublishEvent) BeforeCreate(tx *gorm.DB) (err error) { return } -func (s *ScheduledPublication) BeforeCreate(_ *gorm.DB) (err error) { +func (s *ScheduledPublication) BeforeCreate(tx *gorm.DB) (err error) { if s.ID == uuid.Nil { s.ID = uuid.New() } + if s.WorkspaceID == uuid.Nil { + s.WorkspaceID = deriveWorkspaceIDFromProject(tx, s.ProjectID, s.CreatedBy) + } if s.Status == "" { s.Status = ScheduledPublicationStatusScheduled } diff --git a/backend/internal/models/workspace_identity.go b/backend/internal/models/workspace_identity.go index 4cf0fbeb..06f801a6 100644 --- a/backend/internal/models/workspace_identity.go +++ b/backend/internal/models/workspace_identity.go @@ -58,3 +58,34 @@ func deriveWorkspaceIDFromDocument(db *gorm.DB, documentID uuid.UUID) uuid.UUID } return uuid.Nil } + +func deriveWorkspaceIDFromMediaAsset(db *gorm.DB, mediaAssetID uuid.UUID, projectID *uuid.UUID, fallbackUserID uuid.UUID) uuid.UUID { + if projectID != nil && *projectID != uuid.Nil { + return deriveWorkspaceIDFromProject(db, *projectID, fallbackUserID) + } + + if mediaAssetID == uuid.Nil { + if fallbackUserID != uuid.Nil { + return PersonalWorkspaceID(fallbackUserID) + } + return uuid.Nil + } + + var asset MediaAsset + if err := db.Select("id", "user_id", "workspace_id", "project_id").First(&asset, "id = ?", mediaAssetID).Error; err == nil { + if asset.WorkspaceID != nil && *asset.WorkspaceID != uuid.Nil { + return *asset.WorkspaceID + } + if asset.ProjectID != nil && *asset.ProjectID != uuid.Nil { + return deriveWorkspaceIDFromProject(db, *asset.ProjectID, asset.UserID) + } + if asset.UserID != uuid.Nil { + return PersonalWorkspaceID(asset.UserID) + } + } + + if fallbackUserID != uuid.Nil { + return PersonalWorkspaceID(fallbackUserID) + } + return uuid.Nil +} From 28a9f8a8abe49cccf5cf72a0029d6eced3a6e1b8 Mon Sep 17 00:00:00 2001 From: Kuroda Kayn Date: Sun, 28 Jun 2026 22:53:40 +0800 Subject: [PATCH 3/5] feat(ai): derive workspace ids for AI project rows AI project-domain records also need stable tenant routing before Citus distribution can be introduced. Add AI-specific lifecycle hooks that stamp workspace IDs from the owning project when callers omit the field. Keeping these hooks separate from generic model hooks makes the AI persistence behavior easier to review. --- backend/internal/models/ai_hooks.go | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 backend/internal/models/ai_hooks.go diff --git a/backend/internal/models/ai_hooks.go b/backend/internal/models/ai_hooks.go new file mode 100644 index 00000000..4eef4edc --- /dev/null +++ b/backend/internal/models/ai_hooks.go @@ -0,0 +1,46 @@ +package models + +import ( + "github.com/google/uuid" + "gorm.io/gorm" +) + +func (s *AIContextSnapshot) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + if s.WorkspaceID == uuid.Nil { + s.WorkspaceID = deriveWorkspaceIDFromProject(tx, s.ProjectID, s.CreatedByID) + } + return +} + +func (r *AIGrowthOptimizationRun) BeforeCreate(tx *gorm.DB) (err error) { + if r.ID == uuid.Nil { + r.ID = uuid.New() + } + if r.WorkspaceID == uuid.Nil { + r.WorkspaceID = deriveWorkspaceIDFromProject(tx, r.ProjectID, r.CreatedByID) + } + return +} + +func (p *AIProposal) BeforeCreate(tx *gorm.DB) (err error) { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + if p.WorkspaceID == uuid.Nil { + p.WorkspaceID = deriveWorkspaceIDFromProject(tx, p.ProjectID, uuid.Nil) + } + return +} + +func (s *AIDraftingSession) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + if s.WorkspaceID == uuid.Nil { + s.WorkspaceID = deriveWorkspaceIDFromProject(tx, s.ProjectID, s.CreatedByID) + } + return +} From 576ebd154ce8af92348a8cafde5933166ecf0612 Mon Sep 17 00:00:00 2001 From: Kuroda Kayn Date: Sun, 28 Jun 2026 22:53:47 +0800 Subject: [PATCH 4/5] test(database): cover project row workspace derivation The new model hooks need regression coverage so direct inserts keep tenant routing data. Extend the database derivation test across scheduled publishing, media rows, and AI project-domain records. The assertions verify each row resolves workspace_id through its stable project or media relationship. --- backend/internal/db/db_test.go | 80 ++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/backend/internal/db/db_test.go b/backend/internal/db/db_test.go index 3431e1b9..8ba8b0c7 100644 --- a/backend/internal/db/db_test.go +++ b/backend/internal/db/db_test.go @@ -204,6 +204,86 @@ func TestProjectDomainRowsDeriveWorkspaceID(t *testing.T) { } require.NoError(t, database.Create(&state).Error) require.Equal(t, models.PersonalWorkspaceID(owner.ID), state.WorkspaceID) + + schedule := models.ScheduledPublication{ + ProjectID: project.ID, + PublicationID: publication.ID, + ScheduledAt: time.Now().UTC().Add(time.Hour), + CreatedBy: owner.ID, + } + require.NoError(t, database.Create(&schedule).Error) + require.Equal(t, workspace.ID, schedule.WorkspaceID) + + asset := models.MediaAsset{ + UserID: owner.ID, + ProjectID: &project.ID, + Bucket: "media", + ObjectKey: "workspaces/tenant/project/image.png", + OriginalFilename: "image.png", + MimeType: "image/png", + SizeBytes: 12, + Usage: models.MediaAssetUsageCoverImage, + } + require.NoError(t, database.Create(&asset).Error) + require.NotNil(t, asset.WorkspaceID) + require.Equal(t, workspace.ID, *asset.WorkspaceID) + + assetUsage := models.MediaAssetUsage{ + MediaAssetID: asset.ID, + ProjectID: &project.ID, + ResourceType: "project", + ResourceID: project.ID, + UsageKind: models.MediaAssetUsageCoverImage, + } + require.NoError(t, database.Create(&assetUsage).Error) + require.Equal(t, workspace.ID, assetUsage.WorkspaceID) + + snapshot := models.AIContextSnapshot{ + ProjectID: project.ID, + CreatedByID: owner.ID, + ContextKind: "drafting", + SourceContent: "content", + TokenEstimate: 1, + ContextBudget: 100, + CompactionLevel: "none", + } + require.NoError(t, database.Create(&snapshot).Error) + require.Equal(t, workspace.ID, snapshot.WorkspaceID) + + run := models.AIGrowthOptimizationRun{ + ProjectID: project.ID, + ContextSnapshotID: snapshot.ID, + Goal: "tighten", + Intensity: "balanced", + TargetPlatforms: []byte(`["wechat"]`), + Status: "running", + Model: "test-model", + PromptVersion: "test", + CreatedByID: owner.ID, + } + require.NoError(t, database.Create(&run).Error) + require.Equal(t, workspace.ID, run.WorkspaceID) + + proposal := models.AIProposal{ + ProjectID: project.ID, + RunID: &run.ID, + ContextSnapshotID: snapshot.ID, + ProposalType: "source_rewrite", + TargetPlatform: "wechat", + Status: "proposed", + } + require.NoError(t, database.Create(&proposal).Error) + require.Equal(t, workspace.ID, proposal.WorkspaceID) + + draftingSession := models.AIDraftingSession{ + ProjectID: project.ID, + CreatedByID: owner.ID, + Title: "Drafting", + Status: "active", + LastMessageAt: time.Now().UTC(), + } + require.NoError(t, database.Create(&draftingSession).Error) + require.Equal(t, workspace.ID, draftingSession.WorkspaceID) } func TestSyncSchemaAddsArchiveScanIndexes(t *testing.T) { From 92de6f1b99bd28726f32711b478acf5f6c6257e4 Mon Sep 17 00:00:00 2001 From: Kuroda Kayn Date: Sun, 28 Jun 2026 22:53:54 +0800 Subject: [PATCH 5/5] docs(database): update Citus routing evidence The database optimization plan should point to the implemented workspace derivation evidence. Update the tenant boundary and Citus preparation entries to reference generic and AI hook files plus database tests. This keeps the Phase 5 checklist aligned with the separated model and AI implementation. --- doc/plan/database-optimization.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/plan/database-optimization.md b/doc/plan/database-optimization.md index d44bd7d6..80eb056a 100644 --- a/doc/plan/database-optimization.md +++ b/doc/plan/database-optimization.md @@ -60,14 +60,14 @@ Atomic commit guidance: | Application connection pools | Done | backend/publish-worker support `DB_MAX_OPEN_CONNS`, `DB_MAX_IDLE_CONNS`, `DB_CONN_MAX_LIFETIME`, and `DB_CONN_MAX_IDLE_TIME`; collab-service node-postgres pool supports `DB_MAX_OPEN_CONNS`, `DB_CONN_MAX_LIFETIME`, and `DB_CONN_MAX_IDLE_TIME`; Redis client supports `REDIS_POOL_SIZE`, `REDIS_MIN_IDLE_CONNS`, `REDIS_MAX_IDLE_CONNS`, `REDIS_CONN_MAX_IDLE_TIME`, and `REDIS_CONN_MAX_LIFETIME`; Docker Compose and self-hosted Kubernetes reuse PostgreSQL connections through PgBouncer writer pool; GORM PostgreSQL driver uses simple protocol to remain compatible with transaction pooling; self-hosted Kubernetes added PgBouncer reader pool and points app `DB_READER_HOST` to the reader pool | None | `backend/internal/db/db.go`, `backend/internal/redisclient/redisclient.go`, `collab-service/src/config.ts`, `collab-service/src/persistence/document-persistence.ts`, `collab-service/src/persistence/document-persistence.test.ts`, `deploy/docker/docker-compose.yml`, `deploy/kubernetes/data-services/self-hosted/pgbouncer.yaml`, `deploy/kubernetes/app-baseline/app-config.yaml` | | Query observability | Done | GORM QueryObserver, slow-query logs, `mpp_db_queries_total`, `mpp_db_query_duration_seconds`, `mpp_db_slow_queries_total`, self-hosted PostgreSQL `pg_stat_statements`, PostgreSQL exporter table-level health metrics, Grafana table row count / 24h growth / table size / index size / dead tuples / vacuum panels | Threshold alerts can continue to be added by production baseline in Phase 1/4 | `backend/internal/db/query_observer.go`, `backend/internal/observability/observability.go`, `script/db/audit_database_baseline.sql`, `deploy/docker/observability/postgres-exporter/queries.yml`, `deploy/docker/observability/grafana/dashboards/mpp-observability-baseline.json` | | Dashboard query audit | Done | Existing audit script covers dashboard Count, list, platform filter, publication preload, account query, and active session query plans | Not yet turned into a periodic CI/ops gate | `script/db/audit_dashboard_query_plans.sql` | -| Tenant boundary | In progress | Existing `workspaces`, `workspace_members`, `projects.workspace_id`, and personal workspace rules; project-domain publishing, activity, comment, version, share-link, extension, and collaboration rows now carry `workspace_id` directly, with model hooks or service write paths deriving it from the owning project or document for new writes | Citus colocation groups, distributed-table constraints, and worker payload ownership are still open | `backend/internal/models/models.go`, `backend/internal/models/collab.go`, `backend/internal/db/monthly_partitions.go`, `backend/internal/db/hash_partitions.go`, `collab-service/src/persistence/document-persistence.ts`, `backend/internal/db/db_test.go`, `collab-service/src/persistence/document-persistence.test.ts` | +| Tenant boundary | In progress | Existing `workspaces`, `workspace_members`, `projects.workspace_id`, and personal workspace rules; project-domain publishing, activity, comment, version, share-link, media, AI, extension, and collaboration rows now carry `workspace_id` directly, with model hooks or service write paths deriving it from the owning project, document, or media asset for new writes | Citus colocation groups, distributed-table constraints, and worker payload ownership are still open | `backend/internal/models/hooks.go`, `backend/internal/models/ai_hooks.go`, `backend/internal/models/collab.go`, `backend/internal/db/monthly_partitions.go`, `backend/internal/db/hash_partitions.go`, `collab-service/src/persistence/document-persistence.ts`, `backend/internal/db/db_test.go`, `collab-service/src/persistence/document-persistence.test.ts` | | Dashboard read models | Done | Added `workspace_dashboard_stats` and `project_list_summaries` read models, idempotently recomputed from fact tables by a centralized readmodel service; async refresh is triggered after project save, platform sync, publish completion, and member changes; admin stats and admin project list prefer read models when coverage is complete; admin rebuild API enqueues through Asynq, and API/worker processes can start readmodel workers for full rebuild from fact tables | None | `backend/internal/models/models.go`, `backend/internal/services/readmodel/service.go`, `backend/internal/services/readmodel/queue.go`, `backend/internal/services/readmodel/service_test.go`, `backend/internal/services/readmodel/queue_test.go`, `backend/internal/services/stats/overview.go`, `backend/internal/services/project/lifecycle.go`, `backend/internal/handlers/dashboard.go`, `backend/cmd/api/main.go`, `backend/cmd/publish-worker/main.go` | | Redis read cache | Done | Redis is already used for queues, locks, OAuth, browser sessions, and short-term coordination; admin dashboard stats, admin project list, and dashboard account summary use 15s TTL cache and bypass scoped/sticky-writer strong-consistency paths; stats/project list/account cache misses use singleflight to prevent process-local stampede; stats and account caches use versioned payloads and semantic validation, and Redis read-error fallback is also merged into one DB computation per key; project create/edit/platform save, prepublish sync/draft update, publish queue/execute/fail, and platform account write paths invalidate the related dashboard cache; full read-model rebuild reuses the Redis/Asynq queue | None | `backend/internal/services/stats/overview.go`, `backend/internal/services/stats/overview_test.go`, `backend/internal/services/project/list_cache.go`, `backend/internal/services/project/list_cache_test.go`, `backend/internal/services/prepublish/drafts.go`, `backend/internal/services/publish/service.go`, `backend/internal/services/publish/queue.go`, `backend/internal/services/publish/publication_flow_test.go`, `backend/internal/services/publish/queue_test.go`, `backend/internal/services/platform_account/account_cache.go`, `backend/internal/services/platform_account/account_cache_test.go`, `backend/internal/services/browser_session/complete.go`, `backend/internal/services/browser_session/service_test.go`, `backend/internal/services/readmodel/queue.go` | | Read/write splitting | Done | Supports optional `DB_READER_*` read-replica connection, `DefaultRouter`, and signed sticky writer; project/stats/workspace/platform_account/publish/prepublish/mediaasset/browser_session/extension are wired to strong/eventual/writer routing; dashboard, publish, and collab-service consistency-level inventories are complete, with collab-service online path kept writer-only; writer/reader pools are in self-hosted Kubernetes, and managed overlay provides a `postgres-reader` ExternalName entry point; `DB_READER_MAX_REPLICA_LAG` configures the replica lag threshold, eventual/analytics reads automatically fall back to writer when over threshold or lag is unknown, and `mpp_db_replica_lag_seconds` and `mpp_db_replica_healthy` metrics are exposed | None | `backend/internal/db/db.go`, `backend/internal/db/router.go`, `backend/internal/db/replica_lag.go`, `backend/internal/services/publish/service.go`, `backend/internal/services/prepublish/service.go`, `backend/internal/services/mediaasset/service.go`, `backend/internal/services/browser_session/service.go`, `backend/internal/services/extension/service.go`, `backend/internal/app/runtime.go`, `deploy/kubernetes/data-services/self-hosted/postgres.yaml`, `deploy/kubernetes/data-services/self-hosted/pgbouncer.yaml`, `deploy/kubernetes/data-services/managed/services.yaml`, `script/kubernetes/validation/data_services.rb` | | Event-table partitioning and archiving | Done | `publish_events`, `extension_execution_events`, `project_activities`, `workspace_activities`, and terminal `remote_browser_sessions` have default retention periods; the `archive` worker can batch-export JSONL to R2/S3 and delete old hot-table rows after successful upload; PostgreSQL schema initialization now creates monthly `created_at` partitions for `publish_events`, `extension_execution_events`, `project_activities`, `workspace_activities`, and `remote_browser_sessions`, with partition-compatible `(id, created_at)` primary keys and rolling partition creation; the archive worker exports whole cold monthly partitions as JSONL to R2/S3, then detaches and drops the partition after successful upload; PostgreSQL browser-session active-row fallback uses a scoped advisory transaction lock because partitioned unique constraints must include the partition key; the archive recovery procedure defines inspection, staging restore, optional hot-table reinsertion, and audit checks | None | `backend/internal/db/monthly_partitions.go`, `backend/internal/db/db.go`, `backend/internal/models/models.go`, `backend/internal/db/db_test.go`, `backend/internal/services/browser_session/start.go`, `backend/internal/services/browser_session/cleanup.go`, `backend/internal/services/archive/worker.go`, `backend/internal/services/archive/partitions.go`, `backend/internal/services/archive/worker_test.go`, `backend/internal/services/archive/partitions_test.go`, Phase 4 archive recovery procedure in this document | | Collaboration batch governance | In progress | `collab_document_states`, `collab_document_update_batches`, and compaction/retention foundations exist; PostgreSQL schema initialization creates a 16-way `document_id` hash-partitioned `collab_document_update_batches` target table and migrates existing regular-table rows into it | Cold archiving is not implemented | `backend/internal/db/hash_partitions.go`, `backend/internal/db/db.go`, `backend/internal/models/collab.go`, `backend/internal/db/db_test.go`, `collab-service/src/persistence/document-persistence.ts` | | Outbox/CDC/event stream | In progress | The publishing queue path has a transactional Outbox: `EnqueuePublishProject` writes `outbox_events` in the same transaction and dispatches immediately after commit; publish worker starts an outbox dispatcher and supports retries for failed/stale processing records; Asynq continues to serve as the task-execution queue, and `PublishEvent` continues to serve as publishing audit | Currently covers only `publish.job_requested`; general business-event outbox, Debezium, and Redpanda/Kafka CDC are not implemented | `backend/internal/services/publish/queue.go`, `backend/internal/services/publish/outbox.go`, `backend/internal/models/models.go` | -| Citus target state | In progress | Confirmed `workspace_id` as the most suitable distribution-column direction; project-domain tables now have explicit or stable derived tenant routing coverage | Citus distributed tables, reference tables, colocation, unique-constraint review, and worker payload routing are not implemented | Phase 5 checklist and `backend/internal/db/db_test.go` | +| Citus target state | In progress | Confirmed `workspace_id` as the most suitable distribution-column direction; project-domain tables now have explicit or stable derived tenant routing coverage, including direct-insert fallbacks for scheduled publishing, media, and AI rows | Citus distributed tables, reference tables, colocation, unique-constraint review, and worker payload routing are not implemented | Phase 5 checklist, `backend/internal/models/ai_hooks.go`, and `backend/internal/db/db_test.go` | ### 0.3 Phase Checklist @@ -127,7 +127,7 @@ Atomic commit guidance: - [x] Confirm Workspace as the long-term tenant boundary. - [x] Confirm `workspace_id` as the preferred Citus distribution column. -- [x] Complete `workspace_id` or a stable derivation path for project-domain tables. Verification entry point: `backend/internal/models/models.go`, `backend/internal/models/collab.go`, `backend/internal/db/monthly_partitions.go`, `backend/internal/db/hash_partitions.go`, `collab-service/src/persistence/document-persistence.ts`, `backend/internal/db/db_test.go`, `collab-service/src/persistence/document-persistence.test.ts`. +- [x] Complete `workspace_id` or a stable derivation path for project-domain tables. Verification entry point: `backend/internal/models/hooks.go`, `backend/internal/models/ai_hooks.go`, `backend/internal/models/collab.go`, `backend/internal/db/monthly_partitions.go`, `backend/internal/db/hash_partitions.go`, `collab-service/src/persistence/document-persistence.ts`, `backend/internal/db/db_test.go`, `collab-service/src/persistence/document-persistence.test.ts`. - [ ] Design Citus distributed tables, reference tables, and colocated table groups. - [ ] Review unique constraints, foreign keys, and cross-tenant joins. - [ ] Add `workspace_id` to worker payloads.