Files
Jay Brown 17fc813823 Merged in feature/mutable-metadata1 (pull request #221)
M1, M2 and M3 complete

* M1, M2 and M3 complete

* review changes

* docs

* docs
2026-04-16 23:11:26 +00:00

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
}