learn-platform-deploy/Backend/cmd/api/relational_schema.go

598 lines
32 KiB
Go

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", "subject", "gradeLevel", "enabledModuleTypes", "language", "author", "organization", "settings", "scos", "manifest"}, []string{"enabledModuleTypes", "settings", "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("scorm_runtime_states", []string{"userId", "courseId", "enrollmentId", "scoId", "status", "location", "suspendData", "lessonStatus", "completionStatus", "successStatus", "score", "progress", "sessionTimeSeconds", "totalTimeSeconds", "lastCommittedAt", "values"}, []string{"values"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE"), "courseId": ref("scorm_courses", "CASCADE"), "enrollmentId": ref("enrollments", "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("course_modules", []string{"courseId", "title", "description", "position", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("live_courses", "CASCADE")}),
resource("course_lessons", []string{"courseId", "moduleId", "title", "description", "position", "status", "contentType", "content", "sourceUrl", "durationMinutes", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("live_courses", "CASCADE"), "moduleId": ref("course_modules", "CASCADE")}),
resource("course_lesson_progress", []string{"courseId", "moduleId", "lessonId", "userId", "status", "progress", "startedAt", "completedAt", "lastViewedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("live_courses", "CASCADE"), "moduleId": ref("course_modules", "CASCADE"), "lessonId": ref("course_lessons", "CASCADE"), "userId": ref("users", "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", "progress", "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", "pinned", "editedAt", "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", "editedAt", "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 "community_posts":
return field == "pinned"
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", "position", "durationMinutes", "sessionTimeSeconds", "totalTimeSeconds":
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)
}
if _, err := d.pool.Exec(ctx, `INSERT INTO schema_migrations(version)
VALUES ('20260928_relational_v1')
ON CONFLICT (version) DO NOTHING`); err != nil {
return err
}
_, err := d.pool.Exec(ctx, `INSERT INTO schema_migrations(version)
VALUES ('20260928_learning_runtime_v2')
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
}
// CREATE TABLE IF NOT EXISTS does not evolve an existing production table.
// Add newly declared relational fields idempotently before constraints/indexes.
for _, field := range spec.columnFields() {
if field == "id" {
continue
}
column := relationalColumnName(field)
definition := fmt.Sprintf("%s %s", relationalIdent(column), relationalSQLType(spec, field))
if field == "createdAt" || field == "updatedAt" {
definition += " NOT NULL DEFAULT 0"
}
alter := fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS %s", relationalIdent(spec.Table), definition)
if _, err := d.pool.Exec(ctx, alter); err != nil {
return fmt.Errorf("add column %s.%s: %w", spec.Table, column, err)
}
}
for _, field := range []string{"tenantId", "updatedAt", "status", "slug", "userId", "courseId", "communityId", "cohortId", "moduleId", "lessonId", "scoId"} {
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", "course_modules":
return 20
case "live_course_sessions", "live_course_enrollments", "community_posts", "notification_queue", "course_lessons", "scorm_runtime_states":
return 30
case "live_course_attendance", "community_replies", "notification_delivery_attempts", "course_lesson_progress":
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, `"`, `""`) + `"`
}