package main import ( "context" "encoding/json" "fmt" "sort" "strings" ) type relationalResourceSpec struct { Name string Table string Fields []string JSONFields map[string]struct{} References map[string]relationalReference ListRelations map[string]relationalListRelation } type relationalListRelation struct { Table string OwnerColumn string TargetColumn string TargetTable string } type relationalReference struct { Table string OnDelete string } var relationalResources = buildRelationalResourceSpecs() func buildRelationalResourceSpecs() map[string]relationalResourceSpec { specs := []relationalResourceSpec{ resource("tenants", []string{"name", "slug", "description", "status"}, nil, nil), resource("users", []string{"name", "displayName", "email", "username", "keycloakUsername", "role", "status", "gdprErasedAt"}, nil, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("pages", []string{"name", "title", "slug", "status", "visibility", "publishedAt", "layout", "blocks", "content", "seo"}, []string{"layout", "blocks", "content", "seo"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("datasources", []string{"name", "description", "type", "status", "config"}, []string{"config"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("scorm_courses", []string{"title", "description", "status", "version", "visibility", "launchUrl", "scos", "manifest"}, []string{"scos", "manifest"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("scorm_exports", []string{"courseId", "version", "storageKey"}, nil, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("scorm_courses", "CASCADE")}), resource("enrollments", []string{"userId", "courseId", "courseType", "status", "score", "progress", "progressNote", "timeSpentMin", "completedAt", "archivedAt", "archivedReason", "erasedUserId", "gdprErasedAt"}, nil, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}), resource("authoring_runs", []string{"userId", "status", "brief", "proposalRevision", "source", "reviewUrl", "requiresCMSApproval", "proposal", "validation", "idempotencyKeys", "theme", "site", "page"}, []string{"proposal", "validation", "idempotencyKeys", "theme", "site", "page"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}), resource("consent_receipts", []string{"userId", "policyVersion", "policyUrl", "analytics", "marketing", "preferences", "necessary", "revokedAt", "revokedReason", "categories", "receipt"}, []string{"categories", "receipt"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}), resource("data_subject_requests", []string{"userId", "type", "status", "requestedAt", "decidedAt", "mode", "reason", "archivedAt", "archivedReason", "payload", "recordTypes"}, []string{"payload", "recordTypes"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}), resource("webhooks", []string{"name", "url", "secret", "status", "type", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("forms", []string{"name", "title", "slug", "description", "status", "schema", "fields"}, []string{"schema", "fields"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("form_submissions", []string{"formId", "userId", "submittedAt", "values", "data", "metadata"}, []string{"values", "data", "metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "formId": ref("forms", "CASCADE"), "userId": ref("users", "SET NULL")}), resource("sitemap_menus", []string{"name", "description", "items"}, []string{"items"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("audit_events", []string{"actorUserId", "actor", "action", "resource", "resourceId", "result", "timestamp", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "actorUserId": ref("users", "SET NULL")}), resource("assets", []string{"title", "name", "fileName", "description", "status", "visibility", "ownerUserId", "ownerName", "relatedResourceType", "relatedResourceId", "entitlementKey", "protectionLevel", "sourceUrl", "attributionText", "claimStatus", "licence", "license", "requiresAttribution", "allowDownload", "allowReuse", "allowModification", "allowCommercialUse", "encryption", "copyright", "metadata"}, []string{"encryption", "copyright", "metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "ownerUserId": ref("users", "SET NULL")}), resource("asset_access_events", []string{"assetId", "userId", "eventType", "submittedAt", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "assetId": ref("assets", "CASCADE"), "userId": ref("users", "SET NULL")}), resource("asset_claims", []string{"assetId", "userId", "status", "claimStatus", "submittedAt", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "assetId": ref("assets", "CASCADE"), "userId": ref("users", "SET NULL")}), resource("notification_templates", []string{"name", "channel", "subject", "body", "enabled", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("notification_preferences", []string{"userId", "email", "inApp", "sms", "push", "enabled", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}), resource("notification_deliveries", []string{"userId", "templateId", "channel", "to", "subject", "body", "status", "queuedAt", "sentAt", "deliveredAt", "failedAt", "deferredAt", "throttledAt", "provider", "providerMessageId", "throttleReason", "lastError", "idempotencyKey", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL"), "templateId": ref("notification_templates", "SET NULL")}), resource("notification_queue", []string{"deliveryId", "channel", "to", "subject", "body", "templateId", "status", "attemptCount", "nextAttemptAt", "providerHint", "fallbackProvider", "idempotencyKey", "lastError", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "CASCADE"), "templateId": ref("notification_templates", "SET NULL")}), resource("notification_delivery_attempts", []string{"deliveryId", "queueId", "attempt", "attemptStatus", "errorMessage", "providerMessageId"}, nil, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "CASCADE"), "queueId": ref("notification_queue", "SET NULL")}), resource("notification_dead_letters", []string{"deliveryId", "queueId", "channel", "to", "subject", "body", "templateId", "idempotencyKey", "providerHint", "fallbackProvider", "attemptCount", "reason", "movedAt", "replayedAt", "replayId", "deadLetterId", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "SET NULL"), "templateId": ref("notification_templates", "SET NULL")}), resource("notification_rate_limit_policies", []string{"channel", "throughputPerMinute", "burst", "maxRetries", "backoffSeconds", "maxBackoffSeconds", "deferredSeconds", "perTemplatePerHour", "perRecipientPerHour", "quietHoursStartUtc", "quietHoursEndUtc", "channelEnabled", "throttledAction", "fallbackProvider"}, nil, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("notification_provider_configs", []string{"provider", "enabled", "apiKey", "fromEmail", "apiBaseURL", "credentialMode", "source", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("notifications", []string{"userId", "title", "message", "read", "channel", "status", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}), resource("blog_categories", []string{"name", "slug", "description", "status"}, nil, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("blog_tags", []string{"name", "slug", "description", "status"}, nil, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("blog_authors", []string{"name", "slug", "email", "bio", "userId", "status", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}), resource("blog_posts", []string{"title", "slug", "status", "visibility", "excerpt", "content", "authorId", "categoryId", "publishedAt", "scheduledFor", "featured", "featuredImage", "seo", "metadata"}, []string{"content", "featuredImage", "seo", "metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "authorId": ref("blog_authors", "SET NULL"), "categoryId": ref("blog_categories", "SET NULL")}), resource("entitlements", []string{"userId", "key", "value", "active", "status", "scope", "source", "reason", "startsAt", "endsAt", "revokedAt", "graceAt", "convertedFromTrialAt", "convertedFromEntitlementId", "initiatedByUserId", "lastUpdatedByUserId", "platformAccountId", "sourceEventId", "stripeEventId", "stripeEventType", "checkoutSessionId", "metadata"}, []string{"value", "metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE"), "convertedFromEntitlementId": ref("entitlements", "SET NULL")}), resource("live_courses", []string{"title", "slug", "status", "visibility", "description", "deliveryMode", "level", "durationSummary", "communityId", "entitlementKey", "priceId", "organisationId", "publishedAt", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("live_course_cohorts", []string{"courseId", "liveCourseId", "name", "status", "startsAt", "endsAt", "timezone", "capacity", "enrolledCount", "waitlistEnabled", "priceId", "meetingProvider", "defaultMeetingUrl", "organisationId", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("live_courses", "CASCADE"), "liveCourseId": ref("live_courses", "CASCADE")}), resource("live_course_sessions", []string{"courseId", "liveCourseId", "cohortId", "title", "status", "startsAt", "endsAt", "meetingUrl", "recordingAssetId", "communityThreadId", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "cohortId": ref("live_course_cohorts", "CASCADE"), "recordingAssetId": ref("assets", "SET NULL")}), resource("live_course_enrollments", []string{"courseId", "liveCourseId", "cohortId", "userId", "status", "source", "enrolledAt", "completedAt", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "cohortId": ref("live_course_cohorts", "CASCADE"), "userId": ref("users", "CASCADE")}), resource("live_course_attendance", []string{"courseId", "liveCourseId", "cohortId", "sessionId", "enrollmentId", "userId", "status", "joinedAt", "leftAt", "durationSeconds", "notes", "markedBy", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "sessionId": ref("live_course_sessions", "CASCADE"), "enrollmentId": ref("live_course_enrollments", "CASCADE"), "userId": ref("users", "CASCADE")}), resource("communities", []string{"name", "slug", "description", "status", "visibility", "accessPolicy", "requiredEntitlementKey", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("community_channels", []string{"communityId", "name", "slug", "topic", "isPrivate", "status", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE")}), resource("community_memberships", []string{"communityId", "userId", "role", "status", "source", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE"), "userId": ref("users", "CASCADE")}), resource("community_posts", []string{"communityId", "channelId", "authorId", "body", "status", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE"), "channelId": ref("community_channels", "CASCADE"), "authorId": ref("users", "SET NULL")}), resource("community_replies", []string{"postId", "authorId", "body", "status", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "postId": ref("community_posts", "CASCADE"), "authorId": ref("users", "SET NULL")}), resource("keycloak_themes", []string{"name", "slug", "version", "status", "scope", "type", "target", "organizationId", "realm", "client", "createdBy", "updatedBy", "sections", "tokens", "metadata"}, []string{"target", "sections", "tokens", "metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("keycloak_theme_policy", []string{"organizationId", "allowList", "policy", "metadata"}, []string{"allowList", "policy", "metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), resource("keycloak_theme_exports", []string{"themeId", "themeSlug", "checksum", "format", "baseDir", "fileCount", "files", "previewOnly", "metadata"}, []string{"files", "metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "CASCADE")}), resource("keycloak_theme_deployments_current", []string{"deploymentKey", "themeId", "themeSlug", "version", "status", "summary", "target", "realm", "client", "organizationId", "deployedBy", "deployedAt", "rollback", "rollbackThemeId", "rollbackVersion", "errorMessage"}, []string{"target", "rollback"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "CASCADE")}), resource("keycloak_theme_deployments_audit", []string{"deploymentKey", "themeId", "themeSlug", "version", "status", "summary", "target", "realm", "client", "organizationId", "actor", "deployedBy", "deployedAt", "rollback", "rollbackThemeId", "rollbackVersion", "errorMessage"}, []string{"target", "rollback"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "SET NULL")}), resource("platform_settings", []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, nil), resource("admin_settings", []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, nil), resource("billing_customers", []string{"userId", "stripeCustomerId", "email", "lastInitiatedByUserId", "scope", "status", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}), resource("billing_events", []string{"provider", "type", "status", "receivedAt", "stripeCreatedAt", "platformAccountId", "userId", "initiatedByUserId", "processedAt", "ignored", "reason", "scope", "billingCustomerId", "initiatedByTenantId", "isPlatformActor", "checkoutSessionId", "archivedAt", "archivedReason", "data", "metadata"}, []string{"data", "metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL"), "billingCustomerId": ref("billing_customers", "SET NULL")}), resource("billing_price_policies", []string{"priceId", "entitlementKey", "mode", "scope", "durationDays", "trialDays", "graceDays", "status", "metadata"}, []string{"metadata"}, map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}), } for i := range specs { switch specs[i].Name { case "blog_posts": specs[i].ListRelations = map[string]relationalListRelation{ "tagIds": {Table: "blog_post_tags", OwnerColumn: "post_id", TargetColumn: "tag_id", TargetTable: "blog_tags"}, } case "live_courses": specs[i].ListRelations = map[string]relationalListRelation{ "instructorIds": {Table: "live_course_instructors", OwnerColumn: "live_course_id", TargetColumn: "user_id", TargetTable: "users"}, "assetIds": {Table: "live_course_assets", OwnerColumn: "live_course_id", TargetColumn: "asset_id", TargetTable: "assets"}, } case "communities": specs[i].ListRelations = map[string]relationalListRelation{ "allowedRoles": {Table: "community_allowed_roles", OwnerColumn: "community_id", TargetColumn: "role"}, } } } out := make(map[string]relationalResourceSpec, len(specs)) for _, spec := range specs { out[spec.Name] = spec } return out } func resource(name string, fields []string, jsonFields []string, references map[string]relationalReference) relationalResourceSpec { jsonSet := map[string]struct{}{} for _, field := range jsonFields { jsonSet[field] = struct{}{} } return relationalResourceSpec{ Name: name, Table: name, Fields: append([]string{}, fields...), JSONFields: jsonSet, References: references, ListRelations: nil, } } func ref(table, onDelete string) relationalReference { return relationalReference{Table: table, OnDelete: onDelete} } func (s relationalResourceSpec) columnFields() []string { fields := []string{"id", "tenantId", "createdAt", "updatedAt"} fields = append(fields, s.Fields...) return uniqueStrings(fields) } func (s relationalResourceSpec) allFields() []string { fields := s.columnFields() for field := range s.ListRelations { fields = append(fields, field) } return uniqueStrings(fields) } func (s relationalResourceSpec) hasField(field string) bool { for _, candidate := range s.allFields() { if candidate == field { return true } } return false } func (s relationalResourceSpec) hasColumn(field string) bool { for _, candidate := range s.columnFields() { if candidate == field { return true } } return false } func (s relationalResourceSpec) isJSON(field string) bool { _, ok := s.JSONFields[field] return ok } func relationalColumnName(field string) string { if field == "tenantId" { return "tenant_id" } var b strings.Builder for i, r := range field { if r >= 'A' && r <= 'Z' { if i > 0 { b.WriteByte('_') } b.WriteRune(r + ('a' - 'A')) continue } b.WriteRune(r) } return b.String() } func relationalSQLType(spec relationalResourceSpec, field string) string { if spec.isJSON(field) { return "JSONB" } if isBooleanResourceField(spec.Name, field) { return "BOOLEAN" } if isNumericField(field) { if field == "score" || field == "progress" { return "DOUBLE PRECISION" } return "BIGINT" } return "TEXT" } func isBooleanResourceField(resourceName, field string) bool { switch resourceName { case "consent_receipts": return field == "analytics" || field == "marketing" || field == "preferences" || field == "necessary" case "assets": return field == "requiresAttribution" || field == "allowDownload" || field == "allowReuse" || field == "allowModification" || field == "allowCommercialUse" case "notification_templates": return field == "enabled" case "notification_preferences": return field == "email" || field == "inApp" || field == "sms" || field == "push" || field == "enabled" case "notification_rate_limit_policies": return field == "channelEnabled" case "notification_provider_configs": return field == "enabled" case "notifications": return field == "read" case "entitlements": return field == "active" case "live_course_cohorts": return field == "waitlistEnabled" case "community_channels": return field == "isPrivate" case "authoring_runs": return field == "requiresCMSApproval" case "blog_posts": return field == "featured" case "keycloak_theme_exports": return field == "previewOnly" case "billing_events": return field == "ignored" || field == "isPlatformActor" default: return false } } func isNumericField(field string) bool { if strings.HasSuffix(field, "At") { return true } switch field { case "createdAt", "updatedAt", "score", "progress", "timeSpentMin", "proposalRevision", "fileCount", "capacity", "enrolledCount", "durationSeconds", "quantity", "attempt", "attemptCount", "retryCount", "throughputPerMinute", "burst", "maxRetries", "backoffSeconds", "maxBackoffSeconds", "deferredSeconds", "perTemplatePerHour", "perRecipientPerHour", "quietHoursStartUtc", "quietHoursEndUtc", "durationDays", "trialDays", "graceDays", "limit": return true default: return false } } func (d *pgDatabase) ensureRelationalSchema(ctx context.Context) error { if _, err := d.pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() )`); err != nil { return err } names := make([]string, 0, len(relationalResources)) for name := range relationalResources { names = append(names, name) } sort.SliceStable(names, func(i, j int) bool { return relationalTableRank(names[i]) < relationalTableRank(names[j]) }) for _, name := range names { spec := relationalResources[name] if err := d.ensureRelationalTable(ctx, spec, false); err != nil { return fmt.Errorf("create %s: %w", spec.Table, err) } } for _, name := range names { spec := relationalResources[name] if err := d.ensureRelationalReferences(ctx, spec); err != nil { return fmt.Errorf("reference %s: %w", spec.Table, err) } } for _, name := range names { if err := d.ensureListRelationTables(ctx, relationalResources[name]); err != nil { return fmt.Errorf("list relations %s: %w", name, err) } } if err := d.migrateLegacyDocumentTable(ctx); err != nil { return fmt.Errorf("migrate legacy document store: %w", err) } _, err := d.pool.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES ('20260928_relational_v1') ON CONFLICT (version) DO NOTHING`) return err } func (d *pgDatabase) ensureListRelationTables(ctx context.Context, spec relationalResourceSpec) error { fields := make([]string, 0, len(spec.ListRelations)) for field := range spec.ListRelations { fields = append(fields, field) } sort.Strings(fields) for _, field := range fields { relation := spec.ListRelations[field] query := fmt.Sprintf( "CREATE TABLE IF NOT EXISTS %s (%s TEXT NOT NULL REFERENCES %s(id) ON DELETE CASCADE, %s TEXT NOT NULL, PRIMARY KEY (%s, %s))", relationalIdent(relation.Table), relationalIdent(relation.OwnerColumn), relationalIdent(spec.Table), relationalIdent(relation.TargetColumn), relationalIdent(relation.OwnerColumn), relationalIdent(relation.TargetColumn), ) if _, err := d.pool.Exec(ctx, query); err != nil { return err } if relation.TargetTable != "" { constraint := relation.Table + "_" + relation.TargetColumn + "_fk" alter := fmt.Sprintf(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '%s') THEN ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; END IF; END $$;`, constraint, relationalIdent(relation.Table), relationalIdent(constraint), relationalIdent(relation.TargetColumn), relationalIdent(relation.TargetTable), ) if _, err := d.pool.Exec(ctx, alter); err != nil { return err } } } return nil } func (d *pgDatabase) migrateLegacyDocumentTable(ctx context.Context) error { var legacyTable *string if err := d.pool.QueryRow(ctx, "SELECT to_regclass('public.cms_documents')::text").Scan(&legacyTable); err != nil { return err } if legacyTable == nil || strings.TrimSpace(*legacyTable) == "" { return nil } var archiveTable *string if err := d.pool.QueryRow(ctx, "SELECT to_regclass('public.cms_documents_legacy_20260928')::text").Scan(&archiveTable); err != nil { return err } if archiveTable != nil && strings.TrimSpace(*archiveTable) != "" { return fmt.Errorf("legacy archive table already exists") } rows, err := d.pool.Query(ctx, "SELECT collection, payload FROM cms_documents ORDER BY collection, id") if err != nil { return err } grouped := map[string][]Doc{} for rows.Next() { var collection string var raw []byte if err := rows.Scan(&collection, &raw); err != nil { rows.Close() return err } if _, ok := relationalResources[collection]; !ok { rows.Close() return fmt.Errorf("legacy collection %q has no relational mapping", collection) } var doc Doc if err := json.Unmarshal(raw, &doc); err != nil { rows.Close() return fmt.Errorf("decode %s legacy row: %w", collection, err) } grouped[collection] = append(grouped[collection], doc) } if err := rows.Err(); err != nil { rows.Close() return err } rows.Close() tx, err := d.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, "SET CONSTRAINTS ALL DEFERRED"); err != nil { return err } names := make([]string, 0, len(grouped)) for name := range grouped { names = append(names, name) } sort.SliceStable(names, func(i, j int) bool { return relationalTableRank(names[i]) < relationalTableRank(names[j]) }) for _, name := range names { collection := &pgCollection{db: d, spec: relationalResources[name]} for _, doc := range grouped[name] { if err := collection.writeDocument(ctx, tx, doc, true); err != nil { return fmt.Errorf("%s row %v: %w", name, doc["id"], err) } } } if _, err := tx.Exec(ctx, `ALTER TABLE "cms_documents" RENAME TO "cms_documents_legacy_20260928"`); err != nil { return err } return tx.Commit(ctx) } func (d *pgDatabase) ensureRelationalTable(ctx context.Context, spec relationalResourceSpec, _ bool) error { columns := []string{relationalIdent("id") + " TEXT PRIMARY KEY"} for _, field := range spec.columnFields() { if field == "id" { continue } column := relationalColumnName(field) sqlType := relationalSQLType(spec, field) definition := fmt.Sprintf("%s %s", relationalIdent(column), sqlType) if field == "createdAt" || field == "updatedAt" { definition += " NOT NULL DEFAULT 0" } columns = append(columns, definition) } query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s)", relationalIdent(spec.Table), strings.Join(columns, ", ")) if _, err := d.pool.Exec(ctx, query); err != nil { return err } for _, field := range []string{"tenantId", "updatedAt", "status", "slug", "userId", "courseId", "communityId", "cohortId"} { if !spec.hasColumn(field) { continue } column := relationalColumnName(field) indexName := fmt.Sprintf("%s_%s_idx", spec.Table, column) if _, err := d.pool.Exec(ctx, fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)", relationalIdent(indexName), relationalIdent(spec.Table), relationalIdent(column))); err != nil { return err } } return nil } func (d *pgDatabase) ensureRelationalReferences(ctx context.Context, spec relationalResourceSpec) error { fields := make([]string, 0, len(spec.References)) for field := range spec.References { fields = append(fields, field) } sort.Strings(fields) for _, field := range fields { if !spec.hasField(field) { continue } reference := spec.References[field] column := relationalColumnName(field) constraint := fmt.Sprintf("%s_%s_fk", spec.Table, column) query := fmt.Sprintf(`DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = '%s' ) THEN ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(id) ON DELETE %s DEFERRABLE INITIALLY DEFERRED; END IF; END $$;`, constraint, relationalIdent(spec.Table), relationalIdent(constraint), relationalIdent(column), relationalIdent(reference.Table), reference.OnDelete) if _, err := d.pool.Exec(ctx, query); err != nil { return err } } return nil } func relationalTableRank(name string) int { switch name { case "tenants": return 0 case "users", "blog_categories", "blog_tags", "blog_authors", "forms", "scorm_courses", "assets", "communities", "live_courses", "notification_templates", "billing_customers", "keycloak_themes": return 10 case "community_channels", "live_course_cohorts", "notification_deliveries": return 20 case "live_course_sessions", "live_course_enrollments", "community_posts", "notification_queue": return 30 case "live_course_attendance", "community_replies", "notification_delivery_attempts": return 40 default: return 50 } } func uniqueStrings(values []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(values)) for _, value := range values { if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } func relationalIdent(value string) string { return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` }