package store import ( "context" "database/sql" "errors" rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1" "github.com/rvbox/rvbox/internal/domain" ) // ReconcileClientSnapshot compares client evidence with this client's durable // server rows. It intentionally performs no lifecycle mutation yet: callers // receive only the safe local terminate/discard instructions. func (store *Store) ReconcileClientSnapshot(ctx context.Context, clientID string, snapshot *rvboxv1.ReconcileSnapshot) (*rvboxv1.ReconcileResult, error) { if snapshot == nil { return nil, errors.New("missing client reconciliation snapshot") } result := &rvboxv1.ReconcileResult{} for _, client := range snapshot.GetRetainedCommands() { issue, err := domain.ParseUUIDv7(client.GetIssueUuid()) if err != nil { return nil, err } input := domain.ReconcileInput{ClientEvidence: domain.ClientEvidenceRetained, ClientLifecycle: client.GetLifecycle(), ClientRevision: domain.CommandRevision(client.GetCommandRevision()), ClientLastEventSeq: client.GetLastClientEventSeq(), ImmutableHashMatches: true} if client.GetTombstoned() { input.ClientEvidence = domain.ClientEvidenceTombstone } var lifecycle uint32 var revision, lastSequence uint64 var hash []byte err = store.db.QueryRowContext(ctx, `SELECT lifecycle, revision, last_event_seq, immutable_request_sha256 FROM commands WHERE issue_uuid = ? AND client_id = ?`, issue[:], clientID).Scan(&lifecycle, &revision, &lastSequence, &hash) if err == nil { input.ServerPresent = true input.ServerLifecycle = rvboxv1.CommandLifecycle(lifecycle) input.ServerRevision = domain.CommandRevision(revision) input.ServerLastEventSeq = lastSequence input.ImmutableHashMatches = len(hash) == len(client.GetImmutableRequestSha256()) && string(hash) == string(client.GetImmutableRequestSha256()) } else if !errors.Is(err, sql.ErrNoRows) { return nil, err } else { var tombstoneHash []byte err = store.db.QueryRowContext(ctx, `SELECT immutable_sha256 FROM command_tombstones WHERE issue_uuid = ? AND client_id = ?`, issue[:], clientID).Scan(&tombstoneHash) if err == nil { input.ServerHasTombstone = true input.ImmutableHashMatches = len(tombstoneHash) == len(client.GetImmutableRequestSha256()) && string(tombstoneHash) == string(client.GetImmutableRequestSha256()) } else if !errors.Is(err, sql.ErrNoRows) { return nil, err } } decision, err := domain.DecideReconciliation(input) if err != nil && !errors.Is(err, domain.ErrReconcileContradiction) { return nil, err } switch decision.Action { case domain.ReconcileTerminateLocal: result.TerminateLocalIssueUuids = append(result.TerminateLocalIssueUuids, issue.String()) case domain.ReconcileDiscardLocalTerminal: result.DiscardLocalTerminalIssueUuids = append(result.DiscardLocalTerminalIssueUuids, issue.String()) } } return result, nil }