17fc813823
M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
171 lines
6.1 KiB
Go
171 lines
6.1 KiB
Go
// 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
|
|
}
|