// Package customschema: Milestone 3 (reset-metadata) service // implementation. Lives in its own file so Milestone 3 owns a disjoint // partition from Milestone 2's service_metadata.go (see tracking §2.9). package customschema import ( "context" "errors" "fmt" "time" "queryorchestration/internal/database/repository" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // ResetDocumentMetadata wipes a document's custom metadata history and // clears its custom_schema_id binding in a single transaction. The // operation is idempotent: calling it on a document that has no schema // bound and no metadata rows succeeds with MetadataVersionsDeleted == 0 // and all PreviousSchema* fields nil. // // Transaction sequence (single tx, in exact order): // // 1. LockDocumentForReset — FOR UPDATE on the documents row. // ErrDocumentNotFound when the row is missing. // 2. HasFieldExtraction — mutual-exclusivity guard. A document carrying // any legacy field extraction rows is rejected with // ErrMutualExclusivityViolation before any DELETE runs. // 3. GetDocumentSchemaBindingForReset — capture PreviousSchema* fields // for the response. // 4. CountDocumentCustomMetadataVersions — capture count for the // response under the same lock so it is authoritative at commit. // 5. DeleteDocumentCustomMetadataForReset — DELETE every metadata row. // 6. NullifyDocumentCustomSchemaIdForReset — UPDATE custom_schema_id // to NULL. Trigger 1 allows this because step 5 already cleared // metadata and step 2 already verified no legacy extractions. // 7. Post-commit audit entry (metadata.reset). // // The actor argument is the authenticated Cognito subject and is the // only source of the ResetBy field on the result; the handler never // reads an actor from the request body. func (s *Service) ResetDocumentMetadata(ctx context.Context, documentID uuid.UUID, actor string) (*ResetMetadataResult, error) { if documentID == uuid.Nil { return nil, fmt.Errorf("customschema: document id is required") } result := &ResetMetadataResult{ DocumentID: documentID, ResetBy: actor, } var docClientID string txErr := s.cfg.ExecuteDBTransaction(ctx, func(txCtx context.Context, q *repository.Queries) error { clientID, err := s.resetDocumentMetadataTx(txCtx, q, documentID, result) if err != nil { return err } docClientID = clientID return nil }) if txErr != nil { return nil, txErr } // Populate ResetAt at commit time so the response timestamp and the // audit entry agree. This value is also written into the audit // record below. result.ResetAt = time.Now().UTC() // Post-commit audit. Resource id is the document id (the thing being // mutated). Details carry previous_schema_id (nilable) and the // version count so downstream log aggregation can compute "how much // history was discarded" without re-reading the tracking plan. details := map[string]any{ "metadata_versions_deleted": result.MetadataVersionsDeleted, } if result.PreviousSchemaID != nil { details["previous_schema_id"] = result.PreviousSchemaID.String() } else { details["previous_schema_id"] = nil } if err := s.audit.Record(ctx, AuditRecord{ Actor: actor, Action: AuditActionMetadataReset, ResourceID: documentID, ClientID: docClientID, Timestamp: result.ResetAt, Details: details, }); err != nil { return nil, fmt.Errorf("customschema: record metadata.reset audit: %w", err) } return result, nil } // resetDocumentMetadataTx runs the transactional body of // ResetDocumentMetadata. Pulled out so the top-level method stays under // 100 lines and the transaction sequence reads top-to-bottom. Mutates // `result` in place so captured fields survive after the closure // returns. Returns the document's client id so the caller can populate // the post-commit audit record. func (s *Service) resetDocumentMetadataTx( txCtx context.Context, q *repository.Queries, documentID uuid.UUID, result *ResetMetadataResult, ) (string, error) { // 1. Parent-row lock. Must be the first DML inside the tx. lockRow, err := q.LockDocumentForReset(txCtx, documentID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", ErrDocumentNotFound } return "", fmt.Errorf("customschema: lock document for reset: %w", err) } docClientID := lockRow.Clientid // 2. Mutual-exclusivity guard. A document with any legacy extraction // rows is not a custom-schema document and must not be reset. hasLegacy, err := q.HasFieldExtraction(txCtx, documentID) if err != nil { return "", fmt.Errorf("customschema: check legacy extractions: %w", err) } if hasLegacy { return "", ErrMutualExclusivityViolation } // 3. Capture previousSchema* for the response. The LEFT JOIN returns // NULL name/version when no schema is bound — propagate as nil. binding, err := q.GetDocumentSchemaBindingForReset(txCtx, documentID) if err != nil { return "", fmt.Errorf("customschema: read document schema binding: %w", err) } if binding.CustomSchemaID != nil { idCopy := *binding.CustomSchemaID result.PreviousSchemaID = &idCopy } if binding.SchemaName != nil { nameCopy := *binding.SchemaName result.PreviousSchemaName = &nameCopy } if binding.SchemaVersion != nil { v := int(*binding.SchemaVersion) result.PreviousSchemaVersion = &v } // 4. Count metadata rows BEFORE the delete so the caller learns how // many versions were discarded. count, err := q.CountDocumentCustomMetadataVersions(txCtx, documentID) if err != nil { return "", fmt.Errorf("customschema: count metadata versions: %w", err) } result.MetadataVersionsDeleted = int(count) // 5. Delete every metadata row for this document. if err := q.DeleteDocumentCustomMetadataForReset(txCtx, documentID); err != nil { return "", fmt.Errorf("customschema: delete metadata rows: %w", err) } // 6. Clear the schema binding. Trigger 1 fires on this UPDATE — see // the function-level comment for why both EXISTS checks inside // the trigger return false at this point. if err := q.NullifyDocumentCustomSchemaIdForReset(txCtx, documentID); err != nil { return "", fmt.Errorf("customschema: nullify custom_schema_id: %w", err) } return docClientID, nil }