diff --git a/api/openapi.yaml b/api/openapi.yaml index 6e256ea4d..f4ed813b8 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2187,6 +2187,21 @@ components: notes: type: string type: object + DecidePersonMergeCandidateRequest: + additionalProperties: false + properties: + decision: + enum: + - accept + - reject + type: string + person_id: + format: int64 + type: integer + required: + - person_id + - decision + type: object DeepSearchResponse: additionalProperties: true properties: @@ -4664,6 +4679,15 @@ components: - losing_organization_id - losing_revision type: object + MergePersonRequest: + additionalProperties: false + properties: + absorbed_person_id: + format: int64 + type: integer + required: + - absorbed_person_id + type: object MessageDetail: additionalProperties: true properties: @@ -6882,6 +6906,336 @@ components: - array - "null" type: object + PersonMerge: + additionalProperties: true + properties: + absorbed_person_id: + format: int64 + type: integer + absorbed_revision_before: + format: int64 + type: integer + absorbed_vcard_uid: + type: string + actor: + type: string + created_at: + format: date-time + type: string + current_person_id: + format: int64 + type: integer + id: + format: int64 + type: integer + snapshot_sha256: + type: string + snapshot_version: + format: int64 + type: integer + survivor_person_id: + format: int64 + type: integer + survivor_revision_after: + format: int64 + type: integer + survivor_revision_before: + format: int64 + type: integer + survivor_vcard_uid: + type: string + required: + - id + - survivor_person_id + - absorbed_person_id + - survivor_vcard_uid + - absorbed_vcard_uid + - survivor_revision_before + - absorbed_revision_before + - survivor_revision_after + - actor + - snapshot_version + - snapshot_sha256 + - created_at + type: object + PersonMergeDetail: + additionalProperties: true + properties: + merge: + $ref: "#/components/schemas/PersonMerge" + participants: + items: + $ref: "#/components/schemas/PersonMergeParticipant" + type: + - array + - "null" + review_candidates: + items: + $ref: "#/components/schemas/PersonMergeReviewCandidate" + type: + - array + - "null" + rows: + items: + $ref: "#/components/schemas/PersonMergeRow" + type: + - array + - "null" + splits: + items: + $ref: "#/components/schemas/PersonSplit" + type: + - array + - "null" + required: + - merge + - participants + - rows + - splits + - review_candidates + type: object + PersonMergeParticipant: + additionalProperties: true + properties: + merge_id: + format: int64 + type: integer + origin_side: + type: string + participant_id: + format: int64 + type: integer + split_id: + format: int64 + type: integer + required: + - merge_id + - participant_id + - origin_side + type: object + PersonMergeProfile: + additionalProperties: true + properties: + etag: + type: string + person: + $ref: "#/components/schemas/Person" + required: + - person + - etag + type: object + PersonMergeRequiredError: + additionalProperties: true + properties: + error: + type: string + message: + type: string + profiles: + items: + $ref: "#/components/schemas/PersonMergeProfile" + type: + - array + - "null" + required: + - error + - message + - profiles + type: object + PersonMergeResult: + additionalProperties: true + properties: + cache_state: + enum: + - ready + - stale + type: string + identity_revision: + format: int64 + type: integer + merge: + $ref: "#/components/schemas/PersonMerge" + person: + $ref: "#/components/schemas/Person" + review_candidates: + items: + $ref: "#/components/schemas/PersonMergeReviewCandidate" + type: + - array + - "null" + required: + - person + - merge + - review_candidates + - identity_revision + - cache_state + type: object + PersonMergeReviewCandidate: + additionalProperties: true + properties: + absorbed_value_id: + format: int64 + type: integer + created_at: + format: date-time + type: string + definition_id: + format: int64 + type: integer + id: + format: int64 + type: integer + merge_id: + format: int64 + type: integer + person_id: + format: int64 + type: integer + resolution_value_id: + format: int64 + type: integer + reviewed_at: + format: date-time + type: string + reviewed_by: + type: string + state: + type: string + survivor_value_id: + format: int64 + type: integer + required: + - id + - merge_id + - person_id + - definition_id + - survivor_value_id + - absorbed_value_id + - state + - created_at + type: object + PersonMergeRow: + additionalProperties: true + properties: + action: + type: string + current_row_id: + format: int64 + type: integer + current_row_key: + type: string + merge_id: + format: int64 + type: integer + origin_side: + type: string + original_row_id: + format: int64 + type: integer + original_row_key: + type: string + participant_id: + format: int64 + type: integer + provenance_kind: + type: string + snapshot_path: + type: string + split_id: + format: int64 + type: integer + table_name: + type: string + required: + - merge_id + - table_name + - original_row_key + - origin_side + - provenance_kind + - action + - snapshot_path + type: object + PersonMergeRowRef: + additionalProperties: true + properties: + action: + type: string + original_row_id: + format: int64 + type: integer + original_row_key: + type: string + table_name: + type: string + required: + - table_name + - original_row_key + - action + type: object + PersonMergeSnapshotResponse: + additionalProperties: true + properties: + sha256: + type: string + snapshot: {} + version: + format: int64 + type: integer + required: + - version + - sha256 + - snapshot + type: object + PersonMergeSummary: + additionalProperties: true + properties: + merge: + $ref: "#/components/schemas/PersonMerge" + participant_count: + format: int64 + type: integer + pending_candidate_count: + format: int64 + type: integer + row_action_counts: + additionalProperties: + format: int64 + type: integer + type: object + row_count: + format: int64 + type: integer + split_count: + format: int64 + type: integer + required: + - merge + - participant_count + - row_count + - split_count + - pending_candidate_count + - row_action_counts + type: object + PersonMergesResponse: + additionalProperties: true + properties: + limit: + format: int64 + type: integer + merges: + items: + $ref: "#/components/schemas/PersonMergeSummary" + type: + - array + - "null" + offset: + format: int64 + type: integer + required: + - merges + - limit + - offset + type: object PersonName: additionalProperties: true properties: @@ -7220,15 +7574,101 @@ components: - person - score type: object - PersonSummary: + PersonSplit: additionalProperties: true properties: - activity_count: + actor: + type: string + created_at: + format: date-time + type: string + exact_reversal: + type: boolean + id: format: int64 type: integer - cache_revision: + merge_id: + format: int64 + type: integer + new_person_id: + format: int64 + type: integer + new_person_uid: type: string - cluster: + source_person_id: + format: int64 + type: integer + source_revision_after: + format: int64 + type: integer + source_revision_before: + format: int64 + type: integer + required: + - id + - merge_id + - source_person_id + - new_person_id + - new_person_uid + - source_revision_before + - source_revision_after + - actor + - exact_reversal + - created_at + type: object + PersonSplitResult: + additionalProperties: true + properties: + ambiguous_rows: + items: + $ref: "#/components/schemas/PersonMergeRowRef" + type: + - array + - "null" + cache_state: + enum: + - ready + - stale + type: string + exact_reversal: + type: boolean + identity_revision: + format: int64 + type: integer + new_person: + $ref: "#/components/schemas/Person" + source_person: + $ref: "#/components/schemas/Person" + split: + $ref: "#/components/schemas/PersonSplit" + uid_alias_disposition: + type: string + unrestored_rows: + items: + $ref: "#/components/schemas/PersonMergeRowRef" + type: + - array + - "null" + required: + - split + - source_person + - new_person + - exact_reversal + - uid_alias_disposition + - ambiguous_rows + - unrestored_rows + - identity_revision + - cache_state + type: object + PersonSummary: + additionalProperties: true + properties: + activity_count: + format: int64 + type: integer + cache_revision: + type: string + cluster: $ref: "#/components/schemas/PersonCluster" display_label: type: string @@ -8515,6 +8955,23 @@ components: required: - accounts type: object + SplitPersonRequest: + additionalProperties: false + properties: + merge_id: + format: int64 + type: integer + participant_ids: + items: + format: int64 + type: integer + type: + - array + - "null" + required: + - merge_id + - participant_ids + type: object StageDeletionFilter: additionalProperties: false properties: @@ -9580,7 +10037,7 @@ components: type: apiKey info: title: msgvault API - version: 2.8.0 + version: 2.9.0 openapi: 3.1.0 paths: /api/ping: @@ -14335,6 +14792,14 @@ paths: schema: $ref: "#/components/schemas/IdentityLinkResponse" description: OK + "409": + content: + application/json: + schema: + anyOf: + - $ref: "#/components/schemas/PersonMergeRequiredError" + - $ref: "#/components/schemas/ErrorResponse" + description: Conflict default: content: application/json: @@ -14426,8 +14891,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error + anyOf: + - $ref: "#/components/schemas/PersonMergeRequiredError" + - $ref: "#/components/schemas/ErrorResponse" + description: Conflict "503": content: application/json: @@ -17296,6 +17763,155 @@ paths: summary: Search one durable person's analytical files tags: - Exploration + /api/v1/people/{id}/merge: + post: + operationId: mergePersons + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Exactly two comma-separated strong person revision tags, one for each profile + in: header + name: If-Match + required: true + schema: + type: string + - description: Opaque 1..128-byte retry key + in: header + name: Idempotency-Key + required: true + schema: + maxLength: 128 + minLength: 1 + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MergePersonRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergeResult" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Merge one durable person profile into another + tags: + - API + /api/v1/people/{id}/merges: + get: + operationId: listPersonMerges + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Maximum results + in: query + name: limit + schema: + format: int64 + type: integer + - description: Results to skip + in: query + name: offset + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergesResponse" + description: OK + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List merge history for a durable person + tags: + - API /api/v1/people/{id}/profile: get: description: Returns only current structured values at one person revision. Superseded values and archive observations are available from the separate history endpoint. @@ -17593,6 +18209,100 @@ paths: summary: List one person's relationships tags: - API + /api/v1/people/{id}/split: + post: + operationId: splitPersonMerge + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. + in: header + name: If-Match + required: true + schema: + type: string + - description: Opaque 1..128-byte retry key + in: header + name: Idempotency-Key + required: true + schema: + maxLength: 128 + minLength: 1 + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SplitPersonRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonSplitResult" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + X-New-Person-ETag: + description: Strong revision tag for the new person created by a split + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Split absorbed participant lineage into a new person + tags: + - API /api/v1/people/{id}/tracking: get: operationId: getPersonTracking @@ -17686,6 +18396,202 @@ paths: summary: Replace a person's tracking state tags: - API + /api/v1/person-merge-candidates/{candidate_id}/decision: + post: + operationId: decidePersonMergeCandidate + parameters: + - description: Person merge review candidate ID + in: path + name: candidate_id + required: true + schema: + format: int64 + minimum: 1 + type: integer + - description: Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DecidePersonMergeCandidateRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergeReviewCandidate" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Accept or reject a person merge attribute candidate + tags: + - API + /api/v1/person-merges/{merge_id}: + get: + operationId: getPersonMerge + parameters: + - description: Durable person merge ID + in: path + name: merge_id + required: true + schema: + format: int64 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergeDetail" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Inspect one durable person merge + tags: + - API + /api/v1/person-merges/{merge_id}/snapshot: + get: + operationId: getPersonMergeSnapshot + parameters: + - description: Durable person merge ID + in: path + name: merge_id + required: true + schema: + format: int64 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergeSnapshotResponse" + description: OK + headers: + Cache-Control: + description: Always no-store because the response contains merge provenance + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Read and verify one person merge snapshot + tags: + - API /api/v1/person-relationship-reviews: get: operationId: listPersonRelationshipReviews diff --git a/cmd/msgvault/cmd/create_subset.go b/cmd/msgvault/cmd/create_subset.go index dca60f56d..1ece31163 100644 --- a/cmd/msgvault/cmd/create_subset.go +++ b/cmd/msgvault/cmd/create_subset.go @@ -117,8 +117,13 @@ func runCreateSubset(cmd *cobra.Command, args []string) error { "WARNING: --include-profiles copies every included person's current and historical structured profile values, media, contact observations, relationships, and provenance metadata, plus their employment history and the referenced organizations' profiles, contacts, and media.") } if subsetIncludeVCardResources { - fmt.Fprintln(os.Stderr, - "WARNING: --include-vcard-resources copies every included person's complete native vCard bodies and their retired-UID aliases. A body is copied whole and stays opaque, so it may carry custom properties and RELATED entries naming people outside the subset.") + warning := "WARNING: --include-vcard-resources copies every included person's complete native vCard bodies and retired-UID aliases. A body is copied whole and stays opaque, so it may carry custom properties and RELATED entries naming people outside the subset." + if subsetIncludeAttributes { + warning += " Complete merge packets are also copied when their dependency closure is present; packets retain immutable merge-time values even after later redaction." + } else { + warning += " Merge packets require --include-attributes and will be reported as omitted." + } + fmt.Fprintln(os.Stderr, warning) } result, err := store.CopySubsetWithOptions(srcDBPath, dstDir, subsetRows, @@ -144,6 +149,10 @@ func runCreateSubset(cmd *cobra.Command, args []string) error { fmt.Printf("Organizations: %d\n", result.Organizations) fmt.Printf("Employments: %d\n", result.Employments) } + if result.PersonMergePackets > 0 || result.OmittedPersonMergePackets > 0 { + fmt.Printf("Merge packets: %d copied, %d omitted\n", + result.PersonMergePackets, result.OmittedPersonMergePackets) + } fmt.Printf("Database size: %s\n", formatSize(result.DBSize)) if int64(subsetRows) > result.Messages { diff --git a/cmd/msgvault/cmd/create_subset_test.go b/cmd/msgvault/cmd/create_subset_test.go index 900f6bf3f..e7155ecb1 100644 --- a/cmd/msgvault/cmd/create_subset_test.go +++ b/cmd/msgvault/cmd/create_subset_test.go @@ -123,6 +123,8 @@ func TestCreateSubsetVCardResourcesRequireFlag(t *testing.T) { }) assert.Contains(resourcesStderr, "WARNING: --include-vcard-resources", "the opt-in must state what it exposes before copying it") + assert.Contains(resourcesStderr, + "Merge packets require --include-attributes and will be reported as omitted.") resources := openSubset(subsetOutput) copied, err := resources.GetVCardResourceEnvelopeContext( ctx, "address-book", "source-bob") diff --git a/cmd/msgvault/cmd/person.go b/cmd/msgvault/cmd/person.go index 3f3e9e36a..d8f682a9b 100644 --- a/cmd/msgvault/cmd/person.go +++ b/cmd/msgvault/cmd/person.go @@ -208,6 +208,426 @@ var personDeleteCmd = &cobra.Command{ }, } +var ( + personMergeCmd = newPersonMergeCommand() + personSplitCmd = newPersonSplitCommand() + personMergeHistoryCmd = newPersonMergeHistoryCommand() + personMergeShowCmd = newPersonMergeShowCommand() + personMergeCandidateCmd = newPersonMergeCandidateCommand() +) + +func newPersonMergeCommand() *cobra.Command { + var survivorRevision, absorbedRevision int64 + var idempotencyKey string + var jsonOutput bool + command := &cobra.Command{ + Use: "merge ", + Short: "Merge one durable person profile into another", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + survivorID, err := positivePersonCLIArg(cmd, args[0], "survivor person") + if err != nil { + return err + } + absorbedID, err := positivePersonCLIArg(cmd, args[1], "absorbed person") + if err != nil { + return err + } + if survivorID == absorbedID { + return usageErr(cmd, errors.New("survivor and absorbed person must differ")) + } + if err := positivePersonCLIRevision(cmd, survivorRevision, "survivor"); err != nil { + return err + } + if err := positivePersonCLIRevision(cmd, absorbedRevision, "absorbed"); err != nil { + return err + } + idempotencyKey, err = personCLIIdempotencyKey(cmd, idempotencyKey) + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + body := generated.MergePersonsBody{AbsorbedPersonID: absorbedID} + resp, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.MergePersonsResp, error) { + return api.MergePersonsWithResponse(cmd.Context(), + &generated.MergePersonsRequestOptions{ + PathParams: &generated.MergePersonsPath{ID: survivorID}, + Header: &generated.MergePersonsHeaders{ + IfMatch: personMergeCLIIfMatch( + survivorID, survivorRevision, absorbedID, absorbedRevision), + IdempotencyKey: idempotencyKey, + }, + Body: &body, + }) + }) + if err != nil { + return err + } + if jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(resp.JSON200) + } + writePersonMergeResult(cmd, resp.JSON200) + return nil + }, + } + command.Flags().Int64Var(&survivorRevision, "survivor-revision", 0, + "Expected survivor person revision") + command.Flags().Int64Var(&absorbedRevision, "absorbed-revision", 0, + "Expected absorbed person revision") + command.Flags().StringVar(&idempotencyKey, "idempotency-key", "", + "Opaque retry key for this merge") + command.Flags().BoolVar(&jsonOutput, flagJSON, false, "Output as JSON") + return command +} + +func newPersonSplitCommand() *cobra.Command { + var mergeID, revision int64 + var participantIDs []int64 + var idempotencyKey string + var jsonOutput bool + command := &cobra.Command{ + Use: "split ", + Short: "Reverse merged profile lineage into a new person", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sourceID, err := positivePersonCLIArg(cmd, args[0], "source person") + if err != nil { + return err + } + if mergeID <= 0 { + return usageErr(cmd, errors.New("merge ID must be a positive integer")) + } + if err := positivePersonCLIRevision(cmd, revision, "source"); err != nil { + return err + } + if err := validatePersonCLIParticipants(cmd, participantIDs); err != nil { + return err + } + idempotencyKey, err = personCLIIdempotencyKey(cmd, idempotencyKey) + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + body := generated.SplitPersonMergeBody{ + MergeID: mergeID, ParticipantIds: participantIDs, + } + resp, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.SplitPersonMergeResp, error) { + return api.SplitPersonMergeWithResponse(cmd.Context(), + &generated.SplitPersonMergeRequestOptions{ + PathParams: &generated.SplitPersonMergePath{ID: sourceID}, + Header: &generated.SplitPersonMergeHeaders{ + IfMatch: personCLIETag(sourceID, revision), IdempotencyKey: idempotencyKey, + }, + Body: &body, + }) + }) + if err != nil { + return err + } + if jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(resp.JSON200) + } + writePersonSplitResult(cmd, resp.JSON200) + return nil + }, + } + command.Flags().Int64Var(&mergeID, "merge-id", 0, "Merge record to split") + command.Flags().Int64SliceVar(&participantIDs, "participant", nil, + "Participant lineage to move; omit for a root-only split") + command.Flags().Int64Var(&revision, "revision", 0, "Expected source person revision") + command.Flags().StringVar(&idempotencyKey, "idempotency-key", "", + "Opaque retry key for this split") + command.Flags().BoolVar(&jsonOutput, flagJSON, false, "Output as JSON") + return command +} + +func newPersonMergeHistoryCommand() *cobra.Command { + var jsonOutput bool + command := &cobra.Command{ + Use: "merge-history ", + Short: "List merge and split history for a durable person", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + personID, err := positivePersonCLIArg(cmd, args[0], "person") + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + resp, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.ListPersonMergesResp, error) { + return api.ListPersonMergesWithResponse(cmd.Context(), + &generated.ListPersonMergesRequestOptions{ + PathParams: &generated.ListPersonMergesPath{ID: personID}, + }) + }) + if err != nil { + return err + } + if jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(resp.JSON200.Merges) + } + return writePersonMergeHistory(cmd, resp.JSON200.Merges) + }, + } + command.Flags().BoolVar(&jsonOutput, flagJSON, false, "Output as JSON") + return command +} + +func newPersonMergeShowCommand() *cobra.Command { + var snapshot, jsonOutput bool + command := &cobra.Command{ + Use: "merge-show ", + Short: "Inspect a durable person merge", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + mergeID, err := positivePersonCLIArg(cmd, args[0], "merge") + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + if snapshot { + resp, loadErr := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.GetPersonMergeSnapshotResp, error) { + return api.GetPersonMergeSnapshotWithResponse(cmd.Context(), + &generated.GetPersonMergeSnapshotRequestOptions{ + PathParams: &generated.GetPersonMergeSnapshotPath{MergeID: mergeID}, + }) + }) + if loadErr != nil { + return loadErr + } + if jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(resp.JSON200) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "Merge snapshot: %d\nVersion: %d\nSHA-256: %s\nSnapshot: %s\n", + mergeID, resp.JSON200.Version, resp.JSON200.Sha256, resp.JSON200.Snapshot) + return nil + } + resp, loadErr := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.GetPersonMergeResp, error) { + return api.GetPersonMergeWithResponse(cmd.Context(), + &generated.GetPersonMergeRequestOptions{ + PathParams: &generated.GetPersonMergePath{MergeID: mergeID}, + }) + }) + if loadErr != nil { + return loadErr + } + if jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(resp.JSON200) + } + writePersonMergeDetail(cmd, resp.JSON200) + return nil + }, + } + command.Flags().BoolVar(&snapshot, "snapshot", false, "Show the verified merge snapshot") + command.Flags().BoolVar(&jsonOutput, flagJSON, false, "Output as JSON") + return command +} + +func newPersonMergeCandidateCommand() *cobra.Command { + var personID, revision int64 + var decision string + var jsonOutput bool + command := &cobra.Command{ + Use: "merge-candidate ", + Short: "Accept or reject a person merge review candidate", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + candidateID, err := positivePersonCLIArg(cmd, args[0], "candidate") + if err != nil { + return err + } + if personID <= 0 { + return usageErr(cmd, errors.New("person ID must be a positive integer")) + } + if err := positivePersonCLIRevision(cmd, revision, "person"); err != nil { + return err + } + mappedDecision, err := personCLICandidateDecision(cmd, decision) + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + body := generated.DecidePersonMergeCandidateBody{ + PersonID: personID, Decision: mappedDecision, + } + resp, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.DecidePersonMergeCandidateResp, error) { + return api.DecidePersonMergeCandidateWithResponse(cmd.Context(), + &generated.DecidePersonMergeCandidateRequestOptions{ + PathParams: &generated.DecidePersonMergeCandidatePath{CandidateID: candidateID}, + Header: &generated.DecidePersonMergeCandidateHeaders{ + IfMatch: personCLIETag(personID, revision), + }, + Body: &body, + }) + }) + if err != nil { + return err + } + if jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(resp.JSON200) + } + personETag := "" + if resp.Headers200 != nil { + personETag = resp.Headers200.ETag + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "Candidate: %d\nMerge: %d\nPerson: %d\nState: %s\nPerson ETag: %s\n", + resp.JSON200.ID, resp.JSON200.MergeID, resp.JSON200.PersonID, + resp.JSON200.State, personETag) + return nil + }, + } + command.Flags().Int64Var(&personID, "person-id", 0, + "Person profile that owns the candidate") + command.Flags().StringVar(&decision, "decision", "", + "Decision: accepted or rejected") + command.Flags().Int64Var(&revision, "revision", 0, "Expected person revision") + command.Flags().BoolVar(&jsonOutput, flagJSON, false, "Output as JSON") + return command +} + +func writePersonMergeResult(cmd *cobra.Command, result *generated.PersonMergeResult) { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "Merge: %d\nSurvivor: %d\nSurvivor UID: %s\nAbsorbed: %d\nAbsorbed UID: %s\n"+ + "Survivor revision: %d -> %d\nAbsorbed revision: %d\n"+ + "Absorbed UID alias: %s -> %s\nReview candidates: %d\n"+ + "Identity revision: %d\nCache state: %s\n", + result.Merge.ID, result.Person.ID, result.Person.VcardUID, + result.Merge.AbsorbedPersonID, result.Merge.AbsorbedVcardUID, + result.Merge.SurvivorRevisionBefore, result.Merge.SurvivorRevisionAfter, + result.Merge.AbsorbedRevisionBefore, result.Merge.AbsorbedVcardUID, + result.Person.VcardUID, len(result.ReviewCandidates), + result.IdentityRevision, result.CacheState) +} + +func writePersonSplitResult(cmd *cobra.Command, result *generated.PersonSplitResult) { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "Split: %d\nMerge: %d\nSource person: %d\nSource UID: %s\n"+ + "New person: %d\nNew UID: %s\nSource revision: %d -> %d\n"+ + "Exact reversal: %t\nUID alias disposition: %s\nAmbiguous rows: %d\n"+ + "Identity revision: %d\nCache state: %s\n", + result.Split.ID, result.Split.MergeID, result.SourcePerson.ID, + result.SourcePerson.VcardUID, result.NewPerson.ID, result.NewPerson.VcardUID, + result.Split.SourceRevisionBefore, result.Split.SourceRevisionAfter, + result.ExactReversal, result.UIDAliasDisposition, len(result.AmbiguousRows), + result.IdentityRevision, result.CacheState) +} + +func writePersonMergeHistory(cmd *cobra.Command, history []generated.PersonMergeSummary) error { + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "MERGE\tSURVIVOR\tABSORBED\tCURRENT\tSPLITS\tPENDING\tROWS") + for _, summary := range history { + current := "-" + if summary.Merge.CurrentPersonID != nil { + current = strconv.FormatInt(*summary.Merge.CurrentPersonID, 10) + } + _, _ = fmt.Fprintf(w, "%d\t%d\t%d\t%s\t%d\t%d\t%d\n", + summary.Merge.ID, summary.Merge.SurvivorPersonID, + summary.Merge.AbsorbedPersonID, current, summary.SplitCount, + summary.PendingCandidateCount, summary.RowCount) + } + if err := w.Flush(); err != nil { + return fmt.Errorf("flush person merge history: %w", err) + } + return nil +} + +func writePersonMergeDetail(cmd *cobra.Command, detail *generated.PersonMergeDetail) { + current := "-" + if detail.Merge.CurrentPersonID != nil { + current = strconv.FormatInt(*detail.Merge.CurrentPersonID, 10) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "Merge: %d\nSurvivor: %d (%s)\nAbsorbed: %d (%s)\nCurrent person: %s\n"+ + "Participants: %d\nRows: %d\nSplits: %d\nReview candidates: %d\nSnapshot SHA-256: %s\n", + detail.Merge.ID, detail.Merge.SurvivorPersonID, detail.Merge.SurvivorVcardUID, + detail.Merge.AbsorbedPersonID, detail.Merge.AbsorbedVcardUID, current, + len(detail.Participants), len(detail.Rows), len(detail.Splits), + len(detail.ReviewCandidates), detail.Merge.SnapshotSha256) +} + +func positivePersonCLIRevision(cmd *cobra.Command, revision int64, kind string) error { + if revision <= 0 { + return usageErr(cmd, fmt.Errorf("%s revision must be a positive integer", kind)) + } + return nil +} + +func personCLIIdempotencyKey(cmd *cobra.Command, value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", usageErr(cmd, errors.New("idempotency key is required")) + } + if len(value) > 128 { + return "", usageErr(cmd, errors.New("idempotency key must be at most 128 bytes")) + } + return value, nil +} + +func validatePersonCLIParticipants(cmd *cobra.Command, participantIDs []int64) error { + seen := make(map[int64]struct{}, len(participantIDs)) + for _, participantID := range participantIDs { + if participantID <= 0 { + return usageErr(cmd, errors.New("participant IDs must be positive integers")) + } + if _, duplicate := seen[participantID]; duplicate { + return usageErr(cmd, fmt.Errorf("duplicate participant ID %d", participantID)) + } + seen[participantID] = struct{}{} + } + return nil +} + +func personCLICandidateDecision( + cmd *cobra.Command, value string, +) (generated.DecidePersonMergeCandidateRequestDecision, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "accepted", "accept": + return generated.Accept, nil + case "rejected", "reject": + return generated.Reject, nil + default: + return "", usageErr(cmd, errors.New("decision must be accepted or rejected")) + } +} + +func personCLIETag(personID, revision int64) string { + return fmt.Sprintf(`"person-%d-r%d"`, personID, revision) +} + +func personMergeCLIIfMatch( + survivorID, survivorRevision, absorbedID, absorbedRevision int64, +) string { + return personCLIETag(survivorID, survivorRevision) + ", " + + personCLIETag(absorbedID, absorbedRevision) +} + func getCLIPerson( cmd *cobra.Command, client *daemonclient.Client, id int64, ) (*generated.GetPersonProfileResp, error) { @@ -258,7 +678,9 @@ func init() { personCmd.AddCommand(newPersonProviderCommand(defaultPersonProviderCommandDeps())) personCmd.AddCommand(personPromoteCmd, personGetCmd, personListCmd, personSetDisplayNameCmd, personDeleteCmd, personTrackCmd, personUntrackCmd, - newPersonFilesCommand(defaultPersonFilesCommandDeps()), personSearchCmd) + personMergeCmd, personSplitCmd, personMergeHistoryCmd, personMergeShowCmd, + personMergeCandidateCmd, newPersonFilesCommand(defaultPersonFilesCommandDeps()), + personSearchCmd) for _, command := range []*cobra.Command{ personPromoteCmd, personGetCmd, personListCmd, personSetDisplayNameCmd, personTrackCmd, personUntrackCmd, diff --git a/cmd/msgvault/cmd/person_test.go b/cmd/msgvault/cmd/person_test.go index e1405e539..df4aa2628 100644 --- a/cmd/msgvault/cmd/person_test.go +++ b/cmd/msgvault/cmd/person_test.go @@ -3,14 +3,18 @@ package cmd import ( "bytes" "encoding/json" + "errors" + "fmt" "net/http" "net/http/httptest" + "os" "sync/atomic" "testing" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.kenn.io/kit/daemon" "go.kenn.io/msgvault/internal/config" "go.kenn.io/msgvault/pkg/client/generated" ) @@ -187,3 +191,252 @@ func TestPersonDeleteSendsIfMatchFromLatestRead(t *testing.T) { assert.Equal(int32(2), requests.Load()) assert.Contains(output.String(), "Deleted person 7") } + +func executePersonMergeCLI(t *testing.T, command *cobra.Command, args ...string) string { + t.Helper() + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + require.NoError(t, command.Execute(), output.String()) + return output.String() +} + +func TestPersonMergeCommandsUseConfiguredRemote(t *testing.T) { + assertions := assert.New(t) + requests := map[string]int{} + splitParticipants := [][]int64{} + personJSON := `{ + "id":7,"vcard_uid":"survivor-uid","revision":4,"participant_ids":[70,90], + "created_at":"2026-08-19T00:00:00Z","updated_at":"2026-08-19T00:01:00Z"}` + mergeJSON := `{ + "id":12,"survivor_person_id":7,"absorbed_person_id":9,"current_person_id":7, + "survivor_vcard_uid":"survivor-uid","absorbed_vcard_uid":"absorbed-uid", + "survivor_revision_before":3,"absorbed_revision_before":2, + "survivor_revision_after":4,"actor":"api","snapshot_version":1, + "snapshot_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at":"2026-08-19T00:01:00Z"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Method + " " + r.URL.Path + requests[key]++ + w.Header().Set("Content-Type", "application/json") + switch key { + case "POST /api/v1/people/7/merge": + assertions.Equal(`"person-7-r3", "person-9-r2"`, r.Header.Get("If-Match")) + assertions.Equal("remote-merge", r.Header.Get("Idempotency-Key")) + var body struct { + AbsorbedPersonID int64 `json:"absorbed_person_id"` + } + if !assertions.NoError(json.NewDecoder(r.Body).Decode(&body)) { + http.Error(w, "invalid merge request", http.StatusBadRequest) + return + } + assertions.Equal(int64(9), body.AbsorbedPersonID) + _, _ = fmt.Fprintf(w, `{"person":%s,"merge":%s,"review_candidates":[], + "identity_revision":42,"cache_state":"ready"}`, + personJSON, mergeJSON) + case "POST /api/v1/people/7/split": + assertions.Equal(`"person-7-r4"`, r.Header.Get("If-Match")) + var body struct { + MergeID int64 `json:"merge_id"` + ParticipantIDs []int64 `json:"participant_ids"` + } + if !assertions.NoError(json.NewDecoder(r.Body).Decode(&body)) { + http.Error(w, "invalid split request", http.StatusBadRequest) + return + } + assertions.Equal(int64(12), body.MergeID) + splitParticipants = append(splitParticipants, body.ParticipantIDs) + if len(body.ParticipantIDs) == 0 { + assertions.Equal("remote-root-split", r.Header.Get("Idempotency-Key")) + } else { + assertions.Equal("remote-split", r.Header.Get("Idempotency-Key")) + } + _, _ = fmt.Fprintf(w, `{ + "source_person":%s, + "new_person":{"id":10,"vcard_uid":"new-uid","revision":1, + "participant_ids":[90],"created_at":"2026-08-19T00:02:00Z", + "updated_at":"2026-08-19T00:02:00Z"}, + "split":{"id":13,"merge_id":12,"source_person_id":7,"new_person_id":10, + "new_person_uid":"new-uid","source_revision_before":4, + "source_revision_after":5,"actor":"api","exact_reversal":true, + "created_at":"2026-08-19T00:02:00Z"}, + "exact_reversal":true,"uid_alias_disposition":"retargeted","ambiguous_rows":[], + "identity_revision":43,"cache_state":"stale"}`, + personJSON) + case "GET /api/v1/people/7/merges": + _, _ = w.Write([]byte(`{"merges":[]}`)) + case "GET /api/v1/person-merges/12": + _, _ = fmt.Fprintf(w, `{"merge":%s,"participants":[],"rows":[],"splits":[],"review_candidates":[]}`, + mergeJSON) + case "GET /api/v1/person-merges/12/snapshot": + _, _ = w.Write([]byte(`{ + "version":1, + "sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "snapshot":{"persons":[{"id":7}],"rows":{"person_names":[1]}} + }`)) + case "POST /api/v1/person-merge-candidates/21/decision": + assertions.Equal(`"person-7-r4"`, r.Header.Get("If-Match")) + var body struct { + PersonID int64 `json:"person_id"` + Decision string `json:"decision"` + } + if !assertions.NoError(json.NewDecoder(r.Body).Decode(&body)) { + http.Error(w, "invalid candidate request", http.StatusBadRequest) + return + } + assertions.Equal(int64(7), body.PersonID) + assertions.Equal("reject", body.Decision) + w.Header().Set("ETag", `"person-7-r5"`) + _, _ = w.Write([]byte(`{ + "id":21,"merge_id":12,"person_id":7,"definition_id":4, + "survivor_value_id":31,"absorbed_value_id":32,"state":"rejected", + "reviewed_by":"api","reviewed_at":"2026-08-19T00:03:00Z", + "created_at":"2026-08-19T00:01:00Z"}`)) + default: + http.Error(w, key, http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + mergeJSONOutput := executePersonMergeCLI(t, newPersonMergeCommand(), "7", "9", + "--survivor-revision", "3", "--absorbed-revision", "2", + "--idempotency-key", "remote-merge", "--json") + assertions.Contains(mergeJSONOutput, `"review_candidates":[]`) + mergeOutput := executePersonMergeCLI(t, newPersonMergeCommand(), "7", "9", + "--survivor-revision", "3", "--absorbed-revision", "2", + "--idempotency-key", "remote-merge") + assertions.Contains(mergeOutput, "Merge: 12") + assertions.Contains(mergeOutput, "Absorbed UID: absorbed-uid") + assertions.Contains(mergeOutput, "Identity revision: 42") + assertions.Contains(mergeOutput, "Cache state: ready") + splitJSONOutput := executePersonMergeCLI(t, newPersonSplitCommand(), "7", "--merge-id", "12", + "--participant", "90", "--revision", "4", + "--idempotency-key", "remote-split", "--json") + assertions.Contains(splitJSONOutput, `"ambiguous_rows":[]`) + splitOutput := executePersonMergeCLI(t, newPersonSplitCommand(), "7", "--merge-id", "12", + "--participant", "90", "--revision", "4", + "--idempotency-key", "remote-split") + assertions.Contains(splitOutput, "Split: 13") + assertions.Contains(splitOutput, "Exact reversal: true") + assertions.Contains(splitOutput, "Identity revision: 43") + assertions.Contains(splitOutput, "Cache state: stale") + rootSplitOutput := executePersonMergeCLI(t, newPersonSplitCommand(), "7", "--merge-id", "12", + "--revision", "4", "--idempotency-key", "remote-root-split", "--json") + assertions.Contains(rootSplitOutput, `"exact_reversal":true`) + assertions.Equal([][]int64{{90}, {90}, nil}, splitParticipants) + executePersonMergeCLI(t, newPersonMergeHistoryCommand(), "7", "--json") + assertions.Contains(executePersonMergeCLI(t, newPersonMergeHistoryCommand(), "7"), "MERGE") + detailJSONOutput := executePersonMergeCLI(t, newPersonMergeShowCommand(), "12", "--json") + for _, field := range []string{"participants", "rows", "splits", "review_candidates"} { + assertions.Contains(detailJSONOutput, `"`+field+`":[]`) + } + assertions.Contains(executePersonMergeCLI(t, newPersonMergeShowCommand(), "12"), "Merge: 12") + snapshot := executePersonMergeCLI(t, newPersonMergeShowCommand(), "12", "--snapshot", "--json") + assertions.JSONEq(`{"persons":[{"id":7}],"rows":{"person_names":[1]}}`, + string(extractPersonMergeSnapshot(t, snapshot))) + assertions.Contains( + executePersonMergeCLI(t, newPersonMergeShowCommand(), "12", "--snapshot"), + `Snapshot: {"persons":[{"id":7}],"rows":{"person_names":[1]}}`) + executePersonMergeCLI(t, newPersonMergeCandidateCommand(), "21", + "--person-id", "7", "--revision", "4", "--decision", "rejected", "--json") + candidateOutput := executePersonMergeCLI(t, newPersonMergeCandidateCommand(), "21", + "--person-id", "7", "--revision", "4", "--decision", "rejected") + assertions.Contains(candidateOutput, "State: rejected") + assertions.Contains(candidateOutput, `Person ETag: "person-7-r5"`) + + for _, key := range []string{ + "POST /api/v1/people/7/merge", "POST /api/v1/people/7/split", + "GET /api/v1/people/7/merges", "GET /api/v1/person-merges/12", + "GET /api/v1/person-merges/12/snapshot", + "POST /api/v1/person-merge-candidates/21/decision", + } { + want := 2 + if key == "POST /api/v1/people/7/split" { + want = 3 + } + assertions.Equal(want, requests[key], key) + } + for _, command := range []*cobra.Command{ + newPersonMergeCommand(), newPersonSplitCommand(), newPersonMergeCandidateCommand(), + } { + assertions.Nil(command.Flags().Lookup("actor")) + } + assertions.Nil(newPersonMergeCandidateCommand().Flags().Lookup("idempotency-key")) +} + +func extractPersonMergeSnapshot(t *testing.T, payload string) json.RawMessage { + t.Helper() + var decoded struct { + Snapshot json.RawMessage `json:"snapshot"` + } + require.NoError(t, json.Unmarshal([]byte(payload), &decoded)) + return decoded.Snapshot +} + +func TestPersonMergeCommandUsesExistingLocalDaemon(t *testing.T) { + requests := 0 + mux := http.NewServeMux() + mux.Handle("/api/ping", daemon.NewPingHandler(daemon.PingHandlerOptions{ + Service: daemonService, Version: Version, + })) + mux.HandleFunc("/api/v1/people/7/merges", func(w http.ResponseWriter, r *http.Request) { + requests++ + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"merges":[]}`)) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + dataDir := t.TempDir() + withStoreResolverConfig(t, lifecycleTestConfig(dataDir)) + runtime := daemonRuntimeForHTTPServer(t, server, daemonAPIKeyFingerprint("")) + _, err := daemonRuntimeStore(dataDir).Write(runtime.Record) + require.NoError(t, err) + stubStartServeBackgroundProcess(t, + func(*config.Config, backgroundServeStartOptions) (*backgroundServeProcess, error) { + require.FailNow(t, "existing local daemon must not trigger autostart") + return nil, errors.New("unreachable") + }) + + output := executePersonMergeCLI(t, newPersonMergeHistoryCommand(), "7", "--json") + assert.JSONEq(t, `[]`, output) + assert.Equal(t, 1, requests) +} + +func TestPersonMergeCLIValidationHappensBeforeOpeningStore(t *testing.T) { + dataDir := t.TempDir() + withStoreResolverConfig(t, lifecycleTestConfig(dataDir)) + tests := []struct { + name string + command *cobra.Command + args []string + want string + }{ + { + name: "merge revisions", command: newPersonMergeCommand(), + args: []string{"1", "2", "--survivor-revision", "0", + "--absorbed-revision", "1", "--idempotency-key", "merge-key"}, + want: "survivor revision must be a positive integer", + }, + { + name: "candidate decision", command: newPersonMergeCandidateCommand(), + args: []string{"1", "--person-id", "1", "--revision", "1", + "--decision", "maybe"}, + want: "decision must be accepted or rejected", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.command.SetArgs(test.args) + err := test.command.Execute() + require.ErrorContains(t, err, test.want) + }) + } + entries, err := os.ReadDir(dataDir) + require.NoError(t, err) + assert.Empty(t, entries, "invalid commands must not initialize the archive") +} diff --git a/cmd/msgvault/cmd/serve.go b/cmd/msgvault/cmd/serve.go index fdc0bd621..4e9696d47 100644 --- a/cmd/msgvault/cmd/serve.go +++ b/cmd/msgvault/cmd/serve.go @@ -2114,6 +2114,42 @@ func (a *storeAPIAdapter) PersonForParticipantsContext( return a.store.PersonForParticipantsContext(ctx, participantIDs) } +func (a *storeAPIAdapter) MergePersonsContext( + ctx context.Context, request store.PersonMergeRequest, +) (*store.PersonMergeResult, error) { + return a.store.MergePersonsContext(ctx, request) +} + +func (a *storeAPIAdapter) SplitPersonMergeContext( + ctx context.Context, request store.PersonSplitRequest, +) (*store.PersonSplitResult, error) { + return a.store.SplitPersonMergeContext(ctx, request) +} + +func (a *storeAPIAdapter) ListPersonMergesPageContext( + ctx context.Context, personID int64, limit, offset int, +) ([]store.PersonMergeSummary, error) { + return a.store.ListPersonMergesPageContext(ctx, personID, limit, offset) +} + +func (a *storeAPIAdapter) GetPersonMergeContext( + ctx context.Context, mergeID int64, +) (*store.PersonMergeDetail, error) { + return a.store.GetPersonMergeContext(ctx, mergeID) +} + +func (a *storeAPIAdapter) GetPersonMergeSnapshotContext( + ctx context.Context, mergeID int64, +) (*store.PersonMergeSnapshotResponse, error) { + return a.store.GetPersonMergeSnapshotContext(ctx, mergeID) +} + +func (a *storeAPIAdapter) DecidePersonMergeCandidateContext( + ctx context.Context, request store.PersonMergeCandidateDecisionRequest, +) (*store.PersonMergeCandidateDecisionResult, error) { + return a.store.DecidePersonMergeCandidateContext(ctx, request) +} + func (a *storeAPIAdapter) GetPersonProfileContext( ctx context.Context, personID int64, ) (*store.PersonProfile, error) { diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ebf079ed0..e6c437ce4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1289,6 +1289,11 @@ msgvault person get [--json] msgvault person set-display-name [--json] msgvault person set-display-name --clear [--json] msgvault person delete +msgvault person merge [flags] +msgvault person split [flags] +msgvault person merge-history [--json] +msgvault person merge-show [--snapshot] [--json] +msgvault person merge-candidate [flags] msgvault person attributes list [--slug ] [--history] [--json] msgvault person attributes set (--value | --value-json ) [flags] @@ -1297,7 +1302,34 @@ msgvault person attributes clear [flags] `promote` is idempotent. `set-display-name` preserves the profile's stable ID and vCard UID. `delete` permanently retires that UID and removes the profile's -participant bindings. +participant bindings. A person with active merge lineage cannot be deleted +until that lineage is fully split. + +`merge` keeps the survivor's ID and vCard UID, moves the absorbed profile into +it, and records a reversible merge packet. Profiles with active CardDAV +publication are rejected. `split` creates a new person and UID; repeat +`--participant` to select multiple absorbed lineages, or omit it for a merge +whose absorbed profile had no participants. + +| Merge flag | Applies to | Description | +|---|---|---| +| `--survivor-revision ` | `merge` | Required expected revision of the surviving person | +| `--absorbed-revision ` | `merge` | Required expected revision of the absorbed person | +| `--idempotency-key ` | `merge`, `split` | Required retry key; reusing it with a different request is rejected | +| `--merge-id ` | `split` | Required merge record to reverse | +| `--participant ` | `split` | Absorbed participant lineage to move; repeat as needed | +| `--revision ` | `split`, `merge-candidate` | Required expected revision of the current person | +| `--person-id ` | `merge-candidate` | Person that owns the review candidate | +| `--decision ` | `merge-candidate` | Resolve a conflicting single-value attribute | +| `--snapshot` | `merge-show` | Read and verify the immutable merge snapshot | +| `--json` | all merge commands | Output structured JSON | + +Exact splits restore the pre-merge profiles when their lineage and referenced +rows remain available. For partial splits, use `--json` to inspect ambiguous or +unrestored rows. Complete merge packets retain merge-time profile values even +after later redaction and require the strongest profile-data options when +copied into a subset. See [People, Profiles, and Source Identities](/usage/people/#merge-duplicate-profiles-and-reverse-a-merge) +for the workflow and lifecycle boundaries. | Attribute flag | Applies to | Description | |---|---|---| diff --git a/docs/internal/person-merge-reversal.md b/docs/internal/person-merge-reversal.md new file mode 100644 index 000000000..01586b92b --- /dev/null +++ b/docs/internal/person-merge-reversal.md @@ -0,0 +1,71 @@ +# Reversible person merges + +Person merges combine two curated profiles without treating either side as +disposable. The selected survivor keeps its person ID and vCard UID. The +absorbed person is deleted only after its bindings and profile rows have been +reconciled inside the same transaction. A split creates a new person and UID; +historical IDs and retired UIDs are never reused. + +## Durable state + +Each merge stores two related forms of history: + +- `person_merges.snapshot_blob` is the SHA-256-verified, canonical snapshot of + both roots and every registered row needed to interpret the merge. This is + the canonical audit payload; normal merge and split operations do not + rewrite it. +- `person_merge_rows` is operational undo state. Later merges and splits can + rebase its current row locators and dispositions while preserving the + snapshot's audited meaning. +- `person_merge_participants` records survivor and absorbed lineage. Split IDs + close selected lineage, and `current_person_id` becomes `NULL` when a merge + has no remaining absorbed lineage. + +Exact reversal means the selected lineage and live dependencies still permit +the pre-merge profiles to be restored. Partial splits move only attributable +rows and report ambiguous or unrestored rows. The implementation must not call +a split exact when any required row was skipped. + +## Lifecycle boundaries + +- Active merge lineage prevents deletion of the current person. A completed + split releases that guard. +- A profile with an active CardDAV publication cannot participate in a merge. + Merging it locally would otherwise change identity while an external address + book still owns publication state for the old profile. +- Snapshots retain merge-time profile values after later live edits or + redaction. Complete subset packets therefore require attributes, profiles, + and native vCard resources together, and the subset command warns that the + packet contains historical personal data. +- There is no separate purge operation. Removing durable history would also + remove the evidence needed to inspect or reverse the merge. + +## Table-registry invariant + +`personMergeTableRegistry` is the closed inventory of direct and polymorphic +references to `persons`. Merge code must classify every such reference before +the absorbed root is deleted. The inventory test reads the live SQLite and +PostgreSQL catalogs and compares every direct foreign key with the registry; +known polymorphic references are asserted explicitly. + +A change that adds a person reference must update the registry and any +table-specific merge or restore semantics in the same change. Both inventory +tests must pass before that schema can ship. + +## Schema-migration rule + +Snapshot rows record their column names and values. Restore statements use +those recorded columns, so a migration that renames or removes one of them can +make historical packets impossible to replay. Adding a required column with no +database default can also prevent recreation of a deleted snapshot row. + +Before changing a snapshotted table, the migration must choose and verify one +forward path: + +1. Keep old snapshot columns and row insertion compatible with the new schema. +2. Transform stored snapshots and journal JSON once during migration, preserve + their meaning, and recompute each snapshot hash. + +The migration must exercise a merge created with the pre-migration schema and +split it after the upgrade on SQLite and PostgreSQL. Do not add a permanent +dual-read fallback for an obsolete packet shape. diff --git a/docs/usage/people.md b/docs/usage/people.md index ff46fa856..24c2a42d3 100644 --- a/docs/usage/people.md +++ b/docs/usage/people.md @@ -126,6 +126,59 @@ msgvault person set-display-name 7 --clear vCard UID forever; promoting the same observed cluster later creates a new person and UID. +## Merge duplicate profiles and reverse a merge + +Merge two durable profiles only after reviewing both people. The first person +survives with the same ID and vCard UID; the second person's participants and +profile data move to it, and the retired UID becomes an alias. Both current +revisions and an idempotency key are required: + +```bash +msgvault person merge 7 12 \ + --survivor-revision 4 \ + --absorbed-revision 2 \ + --idempotency-key merge-7-12 +``` + +Conflicting single-value attributes remain reviewable instead of being +dropped. Inspect the merge and decide each candidate explicitly: + +```bash +msgvault person merge-history 7 +msgvault person merge-show 42 +msgvault person merge-show 42 --snapshot +msgvault person merge-candidate 18 \ + --person-id 7 --revision 5 --decision accepted +``` + +A split creates a new person and a new vCard UID. Select absorbed participant +lineage with repeated `--participant` flags. Omit `--participant` only when the +absorbed profile had no participants: + +```bash +msgvault person split 7 \ + --merge-id 42 \ + --participant 91 \ + --revision 5 \ + --idempotency-key split-42-91 +``` + +An exact reversal restores the two pre-merge profiles when their lineage and +dependencies are still intact. A partial split moves participant-attributable +data instead of guessing; use `--json` to inspect ambiguous or unrestored rows. +An active merge prevents deletion of its current person; complete the split +first. + +Profiles with an active CardDAV publication cannot be merged. This prevents a +local merge from silently reassigning a UID that an external address book is +already syncing. + +Merge snapshots are durable audit data. They retain both profiles' merge-time +values after later live-profile edits or redaction. A subset copies a complete +merge packet only with `--include-attributes`, `--include-profiles`, and +`--include-vcard-resources`; treat that output as containing historical +personal data. + ## Store typed attributes Every archive starts with four person-field definitions: diff --git a/internal/activity/classify.go b/internal/activity/classify.go deleted file mode 100644 index 2cb92acf0..000000000 --- a/internal/activity/classify.go +++ /dev/null @@ -1,318 +0,0 @@ -package activity - -import ( - "slices" - - "go.kenn.io/msgvault/internal/store" -) - -const ( - recipientTypeFrom = "from" - recipientTypeMember = "member" -) - -// DefaultMaxDirectCounterparts is the largest outbound audience that counts -// as direct contact. Larger sends remain visible activity but are classified -// as co-presence rather than a personal interaction with every recipient. -const DefaultMaxDirectCounterparts = 25 - -// Classification is the owner-relative interpretation of one archived native -// event. Every person link is already collapsed to its strongest evidence and -// deterministic representative role. -type Classification struct { - RefKind store.ActivityRefKind - Channel store.ActivityChannel - Direction store.ActivityDirection - OwnerSourceID *int64 - OwnerAddress string - Persons []store.ActivityEventPerson -} - -// Classify applies the same pure rules to incremental and backstop projection. -func Classify(candidate store.ActivityCandidate, maxDirectCounterparts int) Classification { - if maxDirectCounterparts <= 0 { - maxDirectCounterparts = DefaultMaxDirectCounterparts - } - counterparts := collapseCounterparts(candidate.Counterparts) - - meeting := store.IsMeetingMessageType(candidate.MessageType) - result := Classification{ - RefKind: store.RefKindMessage, - Channel: channelFor(candidate.ConversationType, meeting), - } - if meeting { - result.RefKind = store.RefKindMeeting - } - - // Every distinct 'from' counterpart is a sender: recipient storage keeps - // one row per authoring participant, so multiple authors can co-sign one - // message. Ownership of ANY author makes the message outbound; inbound - // requires the owner to appear exclusively among non-authors. Counterpart - // order is a SQL sort key, so direction must not depend on which author - // row happens to come first. Source-native ownership is authoritative on - // its own: a source that knows the owner sent this message outranks - // counterpart resolution, which has no author row at all when the sender - // participant is unresolved. - owningSenderIndex := -1 - for index, counterpart := range counterparts { - if counterpart.RecipientType == recipientTypeFrom && counterpart.IsOwner { - owningSenderIndex = index - break - } - } - - switch { - case candidate.SourceIsFromMe || owningSenderIndex >= 0: - result.Direction = store.DirectionOutbound - if owningSenderIndex >= 0 { - result.OwnerAddress = counterparts[owningSenderIndex].OwnerAddress - } - case ownerAmongNonSenders(counterparts): - result.Direction = store.DirectionInbound - result.OwnerAddress = firstNonSenderOwnerAddress(counterparts) - default: - result.Direction = store.DirectionObserved - } - if result.Direction != store.DirectionObserved && candidate.SourceID > 0 { - sourceID := candidate.SourceID - result.OwnerSourceID = &sourceID - } - - // Ownership is row-scoped above because direction is envelope-authoritative - // per role, but audience counting and person links are owner-relative: one - // participant can be an owner 'from' row AND a non-owner 'member' row (a - // source-native chat sender in conversation_participants with no matching - // account identity). Counting that row toward the broadcast threshold or - // emitting it as contact evidence would create direct activity between the - // owner and their own person, so ownership collapses across every role of - // the same participant — and across every participant of the same person. - ownerParticipants := make(map[int64]struct{}, len(counterparts)) - ownerPersons := make(map[int64]struct{}, len(counterparts)) - for _, counterpart := range counterparts { - if !counterpart.IsOwner { - continue - } - ownerParticipants[counterpart.ParticipantID] = struct{}{} - if counterpart.PersonID != nil { - ownerPersons[*counterpart.PersonID] = struct{}{} - } - } - ownerLinked := func(counterpart store.ActivityCounterpart) bool { - if counterpart.IsOwner { - return true - } - if _, owned := ownerParticipants[counterpart.ParticipantID]; owned { - return true - } - if counterpart.PersonID == nil { - return false - } - _, owned := ownerPersons[*counterpart.PersonID] - return owned - } - - // The audience is person-relative: several alias participants of one - // curated person are ONE counterpart, matching the person-link collapse - // below — otherwise an alias-heavy direct message could cross the - // broadcast threshold and demote real contact to co-presence. Unlinked - // participants count individually. - type audienceKey struct { - person bool - id int64 - } - nonOwnerIDs := make(map[audienceKey]struct{}, len(counterparts)) - for _, counterpart := range counterparts { - if ownerLinked(counterpart) { - continue - } - key := audienceKey{id: counterpart.ParticipantID} - if counterpart.PersonID != nil { - key = audienceKey{person: true, id: *counterpart.PersonID} - } - nonOwnerIDs[key] = struct{}{} - } - broadcast := len(nonOwnerIDs) > maxDirectCounterparts - - for _, counterpart := range counterparts { - if ownerLinked(counterpart) || counterpart.PersonID == nil { - continue - } - isSender := counterpart.RecipientType == recipientTypeFrom - result.Persons = append(result.Persons, store.ActivityEventPerson{ - PersonID: *counterpart.PersonID, - Role: activityRole(counterpart.RecipientType, isSender, meeting), - Evidence: activityEvidence(result.Direction, isSender, broadcast), - }) - } - result.Persons = strongestPersonLinks(result.Persons) - return result -} - -// collapseCounterparts removes duplicate envelope rows for one native -// participant/role before direction and audience rules run. Recipient storage -// deliberately preserves separate envelope aliases for identity discovery; -// activity is person-relative and must count that participant once. Owner -// evidence is merged with OR semantics so SQL row order cannot reverse the -// message direction when one duplicate alias is confirmed for the source. -func collapseCounterparts(counterparts []store.ActivityCounterpart) []store.ActivityCounterpart { - if len(counterparts) < 2 { - return counterparts - } - type key struct { - participantID int64 - recipientType string - } - seen := make(map[key]int, len(counterparts)) - result := make([]store.ActivityCounterpart, 0, len(counterparts)) - for _, counterpart := range counterparts { - k := key{participantID: counterpart.ParticipantID, recipientType: counterpart.RecipientType} - index, found := seen[k] - if !found { - seen[k] = len(result) - result = append(result, counterpart) - continue - } - current := &result[index] - if current.PersonID == nil && counterpart.PersonID != nil { - personID := *counterpart.PersonID - current.PersonID = &personID - } - if counterpart.IsOwner { - current.IsOwner = true - if current.OwnerAddress == "" || - (counterpart.OwnerAddress != "" && counterpart.OwnerAddress < current.OwnerAddress) { - current.OwnerAddress = counterpart.OwnerAddress - } - } - } - return result -} - -func channelFor(conversationType string, meeting bool) store.ActivityChannel { - if meeting { - return store.ChannelMeeting - } - switch conversationType { - case "email_thread": - return store.ChannelEmail - case "group_chat", "direct_chat", "channel": - return store.ChannelChat - default: - return store.ChannelOther - } -} - -func ownerAmongNonSenders(counterparts []store.ActivityCounterpart) bool { - for _, counterpart := range counterparts { - if counterpart.RecipientType != recipientTypeFrom && counterpart.IsOwner { - return true - } - } - return false -} - -func firstNonSenderOwnerAddress(counterparts []store.ActivityCounterpart) string { - for _, counterpart := range counterparts { - if counterpart.RecipientType != recipientTypeFrom && counterpart.IsOwner { - return counterpart.OwnerAddress - } - } - return "" -} - -func activityRole(recipientType string, sender, meeting bool) store.ActivityRole { - switch { - case sender && meeting: - return store.RoleOrganizer - case sender: - return store.RoleSender - case recipientType == recipientTypeMember: - return store.RoleMember - case meeting: - return store.RoleAttendee - default: - return store.RoleAddressed - } -} - -func activityEvidence( - direction store.ActivityDirection, - sender bool, - broadcast bool, -) store.ActivityEvidence { - switch direction { - case store.DirectionOutbound: - if !broadcast { - return store.EvidenceDirect - } - case store.DirectionInbound: - if sender { - return store.EvidenceDirect - } - case store.DirectionObserved: - } - return store.EvidenceCoPresence -} - -func strongestPersonLinks(links []store.ActivityEventPerson) []store.ActivityEventPerson { - if len(links) == 0 { - return nil - } - - strongest := make(map[int64]store.ActivityEventPerson, len(links)) - for _, link := range links { - current, found := strongest[link.PersonID] - if !found || strongerActivityLink(link, current) { - strongest[link.PersonID] = link - } - } - - result := make([]store.ActivityEventPerson, 0, len(strongest)) - for _, link := range strongest { - result = append(result, link) - } - slices.SortFunc(result, func(left, right store.ActivityEventPerson) int { - switch { - case left.PersonID < right.PersonID: - return -1 - case left.PersonID > right.PersonID: - return 1 - default: - return 0 - } - }) - return result -} - -func strongerActivityLink(candidate, current store.ActivityEventPerson) bool { - candidateEvidence := activityEvidencePriority(candidate.Evidence) - currentEvidence := activityEvidencePriority(current.Evidence) - if candidateEvidence != currentEvidence { - return candidateEvidence < currentEvidence - } - return activityRolePriority(candidate.Role) < activityRolePriority(current.Role) -} - -func activityEvidencePriority(evidence store.ActivityEvidence) int { - if evidence == store.EvidenceDirect { - return 0 - } - return 1 -} - -func activityRolePriority(role store.ActivityRole) int { - switch role { - case store.RoleSender: - return 0 - case store.RoleOrganizer: - return 1 - case store.RoleAddressed: - return 2 - case store.RoleAttendee: - return 3 - case store.RoleMember: - return 4 - default: - return 5 - } -} diff --git a/internal/activity/classify_test.go b/internal/activity/classify_test.go index cbc414177..f407cdfd2 100644 --- a/internal/activity/classify_test.go +++ b/internal/activity/classify_test.go @@ -309,7 +309,7 @@ func TestClassifyDirectionRolesAndEvidence(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { assert := assert.New(t) - got := Classify(test.candidate, test.maxDirect) + got := store.ClassifyActivityCandidate(test.candidate, test.maxDirect) assert.Equal(test.wantRefKind, got.RefKind) assert.Equal(test.wantChannel, got.Channel) assert.Equal(test.wantDirection, got.Direction) @@ -329,7 +329,7 @@ func TestClassifyDirectionRolesAndEvidence(t *testing.T) { } } func TestClassifyKeepsOneStrongestDeterministicLinkPerPerson(t *testing.T) { - got := Classify(store.ActivityCandidate{ + got := store.ClassifyActivityCandidate(store.ActivityCandidate{ MessageID: 7, MessageType: "email", ConversationType: "email_thread", @@ -361,7 +361,7 @@ func TestClassifyDefaultsInvalidThresholdAndOtherChannel(t *testing.T) { }) } - got := Classify(store.ActivityCandidate{ + got := store.ClassifyActivityCandidate(store.ActivityCandidate{ MessageID: 8, MessageType: "unknown", ConversationType: "unknown", @@ -379,7 +379,7 @@ func TestClassifyDoesNotReverseOnDuplicateSenderAliases(t *testing.T) { sender := int64(500) recipient := int64(501) - got := Classify(store.ActivityCandidate{ + got := store.ClassifyActivityCandidate(store.ActivityCandidate{ MessageID: 9, MessageType: "email", ConversationType: "email_thread", @@ -405,7 +405,7 @@ func TestClassifyCountsAliasParticipantsOncePerPerson(t *testing.T) { contact := int64(700) other := int64(701) - got := Classify(store.ActivityCandidate{ + got := store.ClassifyActivityCandidate(store.ActivityCandidate{ MessageID: 16, SourceID: 9, MessageType: "email", @@ -433,7 +433,7 @@ func TestClassifyCountsDuplicateAliasesOnceForBroadcasts(t *testing.T) { first := int64(511) second := int64(512) - got := Classify(store.ActivityCandidate{ + got := store.ClassifyActivityCandidate(store.ActivityCandidate{ MessageID: 10, MessageType: "email", ConversationType: "email_thread", diff --git a/internal/activity/projector.go b/internal/activity/projector.go index 0653b00d4..65fdf6832 100644 --- a/internal/activity/projector.go +++ b/internal/activity/projector.go @@ -12,11 +12,12 @@ import ( ) const ( - DefaultBatchSize = 500 - MaxProjectionBatchSize = 10_000 - projectorStaleRetries = 3 - projectorPassRetries = 8 - defaultProjectorTimezone = "UTC" + DefaultBatchSize = 500 + DefaultMaxDirectCounterparts = store.DefaultMaxDirectActivityCounterparts + MaxProjectionBatchSize = 10_000 + projectorStaleRetries = 3 + projectorPassRetries = 8 + defaultProjectorTimezone = "UTC" ) var ( @@ -822,7 +823,7 @@ func (p *Projector) projections( return nil, fmt.Errorf( "activity: date message %d: %w", candidate.MessageID, err) } - classification := Classify(candidate, maxDirectCounterparts) + classification := store.ClassifyActivityCandidate(candidate, maxDirectCounterparts) projection.Event = &store.ActivityEvent{ MessageID: candidate.MessageID, RefKind: classification.RefKind, diff --git a/internal/api/activity_routes.go b/internal/api/activity_routes.go index 0b297993a..d315ec6ae 100644 --- a/internal/api/activity_routes.go +++ b/internal/api/activity_routes.go @@ -188,7 +188,7 @@ func activityDateParam(name, description string) *huma.Param { location := "query" required := false if name == activityDateField { - location = "path" + location = pathKey required = true } parameter := param(name, location, huma.TypeString, description, required) diff --git a/internal/api/attribute_definitions.go b/internal/api/attribute_definitions.go index 634081acb..508bd39c7 100644 --- a/internal/api/attribute_definitions.go +++ b/internal/api/attribute_definitions.go @@ -348,14 +348,14 @@ func attributeDefinitionETag(definition store.AttributeDefinition) string { func addAttributeDefinitionIDParameter(operation *huma.Operation) { operation.Parameters = append(operation.Parameters, &huma.Param{ - Name: "id", In: "path", Required: true, Description: "Attribute definition ID", + Name: "id", In: pathKey, Required: true, Description: "Attribute definition ID", Schema: &huma.Schema{Type: huma.TypeInteger, Format: formatInt64}, }) } func addAttributeDefinitionIfMatchParameter(operation *huma.Operation) { operation.Parameters = append(operation.Parameters, &huma.Param{ - Name: ifMatchHeaderName, In: "header", Required: true, + Name: ifMatchHeaderName, In: headerParamLocation, Required: true, Description: "Strong ETag returned by the latest definition read", Schema: &huma.Schema{Type: huma.TypeString}, }) diff --git a/internal/api/employments.go b/internal/api/employments.go index b23cc0d5a..24054bb65 100644 --- a/internal/api/employments.go +++ b/internal/api/employments.go @@ -495,7 +495,7 @@ func addEmploymentIDParameter(operation *huma.Operation) { } func addEmploymentIfMatchParameter(operation *huma.Operation) { - operation.Parameters = append(operation.Parameters, &huma.Param{Name: ifMatchHeaderName, In: "header", Required: true, Description: "Strong ETag returned by the latest employment read", Schema: &huma.Schema{Type: huma.TypeString}}) + operation.Parameters = append(operation.Parameters, &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, Description: "Strong ETag returned by the latest employment read", Schema: &huma.Schema{Type: huma.TypeString}}) } func addEmploymentETagHeader(response *huma.Response) { diff --git a/internal/api/identity_links.go b/internal/api/identity_links.go index 3e91ca14e..ad9106c2e 100644 --- a/internal/api/identity_links.go +++ b/internal/api/identity_links.go @@ -60,10 +60,12 @@ type IdentityLinkResponse struct { } func (s *Server) registerIdentityLinkRoutes(api huma.API) { - registerAPIV1RawHumaJSONRouteWithRequest[IdentityLinkRequest, IdentityLinkResponse]( - api, "linkIdentityParticipants", http.MethodPost, "/identity/links", - "Assert two participants are the same person", s.handleLinkIdentity, - ) + link := rawAPIV1Operation("linkIdentityParticipants", http.MethodPost, + "/identity/links", "Assert two participants are the same person") + link.RequestBody = jsonRequestBodyFor[IdentityLinkRequest](api) + link.Responses = jsonResponsesFor[IdentityLinkResponse](api) + link.Responses[httpStatusKey(http.StatusConflict)] = personMergeConflictResponseFor(api) + registerRawHumaRoute(api, link, s.handleLinkIdentity) registerAPIV1RawHumaJSONRouteWithRequest[IdentityLinkRequest, IdentityLinkResponse]( api, "unlinkIdentityParticipants", http.MethodPost, "/identity/unlinks", "Remove a link edge between two participants", s.handleUnlinkIdentity, @@ -113,6 +115,9 @@ func (s *Server) handleIdentityLinkMutation( revision, err := mutate(linker, req.ParticipantA, req.ParticipantB) switch { case errors.Is(err, store.ErrPersonBindingConflict): + if s.writePersonMergeRequired(w, r, err) { + return + } writeError(w, http.StatusConflict, "person_binding_conflict", "The identity clusters belong to different person profiles") return diff --git a/internal/api/identity_links_test.go b/internal/api/identity_links_test.go index 90bd3c071..8c4c36bed 100644 --- a/internal/api/identity_links_test.go +++ b/internal/api/identity_links_test.go @@ -156,6 +156,81 @@ func TestLinkIdentity_IndirectEdgeConflict(t *testing.T) { assert.Equal("already_linked", errResp.Error) } +func TestLinkIdentityAcrossPersonsReturnsPersonMergeRequired(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := t.Context() + srv, st := newIdentityLinkTestServer(t) + leftParticipant := st.mustParticipant(t, "merge-required-left@example.com", "Left", "example.com") + rightParticipant := st.mustParticipant(t, "merge-required-right@example.com", "Right", "example.com") + left, _, err := st.CreatePersonFromParticipantContext(ctx, leftParticipant) + require.NoError(err) + right, _, err := st.CreatePersonFromParticipantContext(ctx, rightParticipant) + require.NoError(err) + beforeIdentityRevision, err := st.IdentityRevision() + require.NoError(err) + + response := postIdentityLink(t, srv, "/api/v1/identity/links", IdentityLinkRequest{ + ParticipantA: leftParticipant, + ParticipantB: rightParticipant, + }) + + require.Equal(http.StatusConflict, response.Code, response.Body.String()) + assertPersonMergeRequiredResponse(t, response, *left, *right) + assert.False(linkedParticipants(t, st, leftParticipant, rightParticipant)) + afterIdentityRevision, err := st.IdentityRevision() + require.NoError(err) + assert.Equal(beforeIdentityRevision, afterIdentityRevision) + for _, before := range []*store.Person{left, right} { + after, getErr := st.GetPersonContext(ctx, before.ID) + require.NoError(getErr) + assert.Equal(before.Revision, after.Revision) + } +} + +func TestLinkIdentityMalformedPersonConflictKeepsGenericResponse(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + srv := newFailingIdentityLinkTestServer(t, &store.PersonBindingConflictError{ + PersonIDs: []int64{3, 2, 1}, + }) + + response := postIdentityLink(t, srv, "/api/v1/identity/links", IdentityLinkRequest{ + ParticipantA: 1, + ParticipantB: 2, + }) + + require.Equal(http.StatusConflict, response.Code, response.Body.String()) + var body ErrorResponse + require.NoError(json.Unmarshal(response.Body.Bytes(), &body), response.Body.String()) + assert.Equal("person_binding_conflict", body.Error) + assert.NotContains(response.Body.String(), "person_merge_required") +} + +func assertPersonMergeRequiredResponse( + t *testing.T, response *httptest.ResponseRecorder, want ...store.Person, +) { + t.Helper() + assert := assert.New(t) + var body struct { + Error string `json:"error"` + Message string `json:"message"` + Profiles []struct { + Person store.Person `json:"person"` + ETag string `json:"etag"` + } `json:"profiles"` + } + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &body), response.Body.String()) + assert.Equal("person_merge_required", body.Error) + assert.NotEmpty(body.Message) + require.Len(t, body.Profiles, len(want)) + for index := range want { + assert.Equal(want[index].ID, body.Profiles[index].Person.ID) + assert.Equal(want[index].Revision, body.Profiles[index].Person.Revision) + assert.Equal(personETag(want[index]), body.Profiles[index].ETag) + } +} + func TestLinkIdentity_RefresherFailureReportsStaleWithoutFailingRequest(t *testing.T) { require := require.New(t) assert := assert.New(t) diff --git a/internal/api/identity_match_candidates.go b/internal/api/identity_match_candidates.go index 75094c985..5cce3dd3a 100644 --- a/internal/api/identity_match_candidates.go +++ b/internal/api/identity_match_candidates.go @@ -95,6 +95,7 @@ func (s *Server) registerIdentityMatchRoutes(api huma.API) { accept.Responses = jsonResponsesFor[IdentityMatchAcceptResponse](api) addErrorResponses(api, accept.Responses, http.StatusConflict, http.StatusNotFound, http.StatusServiceUnavailable) + accept.Responses[httpStatusKey(http.StatusConflict)] = personMergeConflictResponseFor(api) registerRawHumaRoute(api, accept, s.handleAcceptIdentityMatchCandidate) reject := rawAPIV1Operation("rejectIdentityMatchCandidate", http.MethodPost, @@ -161,10 +162,16 @@ func (s *Server) handleAcceptIdentityMatchCandidate(w http.ResponseWriter, r *ht return } // An HTTP accept is always an explicit user decision. The store also - // refuses a system accept for every basis except a stable provider ID. + // refuses a system accept for every basis except a stable provider ID. The + // store performs the binding check under the identity lock and restores the + // prior decision if a merge is required, so this endpoint has no TOCTOU + // preflight window. candidate, revision, err := matches.AcceptIdentityMatchCandidateContext( r.Context(), id, "user", request.Notes) if err != nil { + if s.writePersonMergeRequired(w, r, err) { + return + } s.writeIdentityMatchError(w, err) return } diff --git a/internal/api/identity_match_candidates_test.go b/internal/api/identity_match_candidates_test.go index f336cc70d..924dd1f12 100644 --- a/internal/api/identity_match_candidates_test.go +++ b/internal/api/identity_match_candidates_test.go @@ -95,24 +95,36 @@ func TestAcceptIdentityMatchCandidateLinksAndReportsCacheState(t *testing.T) { assert.Contains(members, bob, "accepting must apply the link, not only record it") } -func TestAcceptIdentityMatchCandidateAcrossPersonsIsAConflict(t *testing.T) { +func TestAcceptIdentityMatchCandidateAcrossPersonsReturnsPersonMergeRequired(t *testing.T) { require := require.New(t) assert := assert.New(t) srv, st := newIdentityLinkTestServer(t) candidate, alice, bob := seedMatchCandidate(t, st, store.IdentityMatchStableProviderID) ctx := context.Background() - _, _, err := st.CreatePersonFromParticipantContext(ctx, alice) + left, _, err := st.CreatePersonFromParticipantContext(ctx, alice) require.NoError(err, "promote alice") - _, _, err = st.CreatePersonFromParticipantContext(ctx, bob) + right, _, err := st.CreatePersonFromParticipantContext(ctx, bob) require.NoError(err, "promote bob") + beforeIdentityRevision, err := st.IdentityRevision() + require.NoError(err) response := personRequest(t, srv, http.MethodPost, acceptPath(candidate.ID), nil, "") require.Equal(http.StatusConflict, response.Code, response.Body.String()) - assert.Contains(response.Body.String(), "person_binding_conflict") + assertPersonMergeRequiredResponse(t, response, *left, *right) reloaded, err := st.GetIdentityMatchCandidateContext(ctx, candidate.ID) require.NoError(err, "GetIdentityMatchCandidateContext") - assert.Equal(store.IdentityMatchStateConflict, reloaded.State) + assert.Equal(candidate.State, reloaded.State, "a merge offer must not decide the candidate") + assert.Equal(candidate.UpdatedAt, reloaded.UpdatedAt) + assert.False(linkedParticipants(t, st, alice, bob)) + afterIdentityRevision, err := st.IdentityRevision() + require.NoError(err) + assert.Equal(beforeIdentityRevision, afterIdentityRevision) + for _, before := range []*store.Person{left, right} { + after, getErr := st.GetPersonContext(ctx, before.ID) + require.NoError(getErr) + assert.Equal(before.Revision, after.Revision) + } } func TestRejectIdentityMatchCandidateRetainsTheRow(t *testing.T) { diff --git a/internal/api/middleware.go b/internal/api/middleware.go index b8ddc8178..88ea441be 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -44,22 +44,24 @@ func defaultCORSAllowedMethods() []string { } // defaultCORSAllowedHeaders lists the request headers cross-origin clients -// send: If-Match carries the concurrency token for settings and saved-view -// updates, X-Request-Id the idempotency key for task creation. +// send: If-Match carries concurrency tokens, Idempotency-Key carries person +// merge and split retry keys, and X-Request-Id carries task creation retry keys. func defaultCORSAllowedHeaders() []string { return []string{ "Accept", "Authorization", "Content-Type", ifMatchHeaderName, - "X-API-Key", "X-Request-Id", csrfHeaderName, + idempotencyKeyHeaderName, "X-API-Key", "X-Request-Id", csrfHeaderName, } } -// corsExposedHeaders is the Access-Control-Expose-Headers value: ETag is the -// only non-safelisted response header clients read (settings and saved-view -// concurrency tokens). +// corsExposedHeaders is the Access-Control-Expose-Headers value. ETag carries +// the primary resource concurrency token; X-New-Person-ETag carries the second +// token created by a person split. const ( - corsExposedHeaders = etagHeaderName - etagHeaderName = "ETag" - ifMatchHeaderName = "If-Match" + corsExposedHeaders = etagHeaderName + ", " + newPersonETagOpenAPIHeaderName + etagHeaderName = "ETag" + ifMatchHeaderName = "If-Match" + headerParamLocation = "header" + pathKey = "path" // formatInt64 is the OpenAPI schema format for 64-bit identifiers. formatInt64 = "int64" diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 5d75c1116..a3fb58e41 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -195,6 +195,8 @@ func TestCORSPreflightHeaders(t *testing.T) { "preflight must allow If-Match for settings and saved-view concurrency tokens") assert.Contains(headers, "X-Request-Id", "preflight must allow X-Request-Id for task-creation idempotency keys") + assert.Contains(headers, "Idempotency-Key", + "preflight must allow person merge and split idempotency keys") assert.NotEmpty(w.Header().Get("Access-Control-Max-Age"), "missing Access-Control-Max-Age") } @@ -209,13 +211,13 @@ func TestCORSExposeHeaders(t *testing.T) { name: "exact origin exposes ETag", allowedOrigins: []string{"http://dashboard.example"}, origin: "http://dashboard.example", - wantExposed: "ETag", + wantExposed: "ETag, X-New-Person-ETag", }, { name: "wildcard origin exposes ETag", allowedOrigins: []string{"*"}, origin: "http://localhost:3000", - wantExposed: "ETag", + wantExposed: "ETag, X-New-Person-ETag", }, { name: "unlisted origin gets no expose header", diff --git a/internal/api/openapi.go b/internal/api/openapi.go index a226bef7e..00a566e7e 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -48,7 +48,6 @@ import ( // 1.6.0 adds the browser-session login, bootstrap, and logout routes. Existing // API-key security remains the documented scheme for protected API routes; // cookie authentication is an additive same-origin browser mechanism. -// // 1.7.0 adds optimistic, secret-redacting browser settings reads and writes. // // 1.8.0 adds daemon-owned shared Saved View CRUD with schema-versioned @@ -225,7 +224,11 @@ import ( // Additive (minor bump): existing unfiltered searches are unchanged. // 2.8.0 adds CardDAV account setup, book roles, publication, conflict, and // sync routes. Passwords are request-only and never appear in responses. -const APISchemaVersion = "2.8.0" +// 2.9.0 adds reversible person merge/split mutations, merge history, snapshot +// inspection, and merge-candidate decisions. Mutations require strong person +// revision tags; +// merge and split also require retry-stable Idempotency-Key headers. +const APISchemaVersion = "2.9.0" // OpenAPIDocument builds the API schema from the same Huma route registration // used by the daemon. It binds no socket and needs no database. @@ -625,6 +628,31 @@ func applyClientCodegenExtensions(doc *huma.OpenAPI) { } } } + if response := schemas["PersonMergeSnapshotResponse"]; response != nil { + if snapshot := response.Properties["snapshot"]; snapshot != nil { + if snapshot.Extensions == nil { + snapshot.Extensions = map[string]any{} + } + snapshot.Extensions["x-go-type"] = "json.RawMessage" + snapshot.Extensions["x-go-type-import"] = map[string]any{pathKey: "encoding/json"} + } + } + for schemaName, properties := range map[string][]string{ + "PersonMergeDetail": {"participants", "review_candidates", "rows", "splits"}, + "PersonMergeResult": {"review_candidates"}, + "PersonSplitResult": {"ambiguous_rows"}, + } { + if schema := schemas[schemaName]; schema != nil { + for _, propertyName := range properties { + if property := schema.Properties[propertyName]; property != nil { + if property.Extensions == nil { + property.Extensions = map[string]any{} + } + property.Extensions["x-omitempty"] = false + } + } + } + } for _, schemaName := range []string{"ExploreGroupsHTTPRequest", "FileGroupsHTTPRequest"} { if groups := schemas[schemaName]; groups != nil && groups.Properties["grouping"] != nil { grouping := groups.Properties["grouping"] diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go index 2bcdfedc5..b8a798466 100644 --- a/internal/api/openapi_test.go +++ b/internal/api/openapi_test.go @@ -38,7 +38,7 @@ func TestOpenAPISeparatesParticipantAnalyticsFromDurablePeople(t *testing.T) { assert := assert.New(t) doc := OpenAPIDocument() - assert.Equal("2.8.0", APISchemaVersion) + assert.Equal("2.9.0", APISchemaVersion) for _, path := range []string{ "/api/v1/participants/search", "/api/v1/participants/{id}", @@ -60,11 +60,11 @@ func TestOpenAPISeparatesParticipantAnalyticsFromDurablePeople(t *testing.T) { } func TestAnalyticsCacheReadinessUsesAdditiveSchemaVersion(t *testing.T) { - assert.Equal(t, "2.8.0", APISchemaVersion) + assert.Equal(t, "2.9.0", APISchemaVersion) } func TestPersonFilesUseAdditiveSchemaVersion(t *testing.T) { - assert.Equal(t, "2.8.0", APISchemaVersion) + assert.Equal(t, "2.9.0", APISchemaVersion) } func TestPersonFileRoutesPublishTypedPathIDs(t *testing.T) { @@ -88,7 +88,7 @@ func TestPersonFileRoutesPublishTypedPathIDs(t *testing.T) { func TestOrganizationCreateOpenAPIDocumentsLocationHeader(t *testing.T) { require := require.New(t) - assert.Equal(t, "2.8.0", APISchemaVersion, + assert.Equal(t, "2.9.0", APISchemaVersion, "document and person-file search preserve the organization and employment contract") for _, document := range []*huma.OpenAPI{ OpenAPIDocument(), @@ -399,7 +399,7 @@ func TestOpenAPIFastSearchDocumentsSourceIDs(t *testing.T) { func TestOpenAPIPersonAttributeContract(t *testing.T) { require := require.New(t) assert := assert.New(t) - assert.Equal("2.8.0", APISchemaVersion, + assert.Equal("2.9.0", APISchemaVersion, "activity, identity match review, document search, and person files preserve the structured profile contract") doc := OpenAPIDocument() @@ -472,7 +472,7 @@ func TestOpenAPIPersonProfilePatchUsesWritableEnvelopeShape(t *testing.T) { func TestOpenAPIOrganizationProfilePutDocumentsLimits(t *testing.T) { assertions := assert.New(t) requirements := require.New(t) - assertions.Equal("2.8.0", APISchemaVersion, + assertions.Equal("2.9.0", APISchemaVersion, "organization profile write limits advance the published contract") doc := OpenAPIDocument() path := doc.Paths["/api/v1/organizations/{id}/profile"] @@ -492,7 +492,7 @@ func TestOpenAPIPersonProfileMediaContentContract(t *testing.T) { require := require.New(t) assert := assert.New(t) - assert.Equal("2.8.0", APISchemaVersion, + assert.Equal("2.9.0", APISchemaVersion, "activity, identity match review, document search, and person files preserve the raw profile media contract") doc := OpenAPIDocument() path := doc.Paths["/api/v1/people/{id}/profile/media/{media_id}/content"] @@ -520,7 +520,7 @@ func TestOpenAPIIdentityMatchReviewContract(t *testing.T) { requirements := require.New(t) assertions := assert.New(t) - assertions.Equal("2.8.0", APISchemaVersion, + assertions.Equal("2.9.0", APISchemaVersion, "document and person-file search preserve the identity match review contract") doc := OpenAPIDocument() @@ -564,9 +564,9 @@ func TestOpenAPIMeetingImportContract(t *testing.T) { // routes added in 1.42.0, cache-readiness responses added in 1.43.0, // document search added in 1.44.0, participant/people separation added in // 2.0.0, tracking added in 2.1.0, and participant-scoped files added in - // 2.5.0. Person search in 2.6.0, structured filters in 2.7.0, and CardDAV - // routes in 2.8.0 did not touch it. - assert.Equal("2.8.0", APISchemaVersion, "meeting import is an additive schema release") + // 2.5.0. Person search in 2.6.0, structured filters in 2.7.0, CardDAV routes + // in 2.8.0, and person merge/split operations in 2.9.0 did not touch it. + assert.Equal("2.9.0", APISchemaVersion, "meeting import is an additive schema release") doc := OpenAPIDocument() path := doc.Paths["/api/v1/import/meeting"] @@ -926,6 +926,18 @@ func TestOpenAPIExplorationFiniteRequiredFieldsAreNonNull(t *testing.T) { assertions.Equal(map[string]any{"validate": "required,min=1,max=1"}, clientGrouping.Extensions["x-oapi-codegen-extra-tags"]) } +func TestOpenAPIPersonMergeSnapshotUsesLosslessGoType(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + snapshot := openAPIClientDocument().Components.Schemas.Map()["PersonMergeSnapshotResponse"] + require.NotNil(snapshot) + property := snapshot.Properties["snapshot"] + require.NotNil(property) + assert.Equal("json.RawMessage", property.Extensions["x-go-type"]) + assert.Equal(map[string]any{"path": "encoding/json"}, + property.Extensions["x-go-type-import"]) +} + func TestOpenAPIExploreGroupingEnumUsesServerCatalog(t *testing.T) { dimensions := explorecatalog.GroupingDimensions() want := make([]any, len(dimensions)) diff --git a/internal/api/organizations.go b/internal/api/organizations.go index 35b33eef4..dfc4d9de8 100644 --- a/internal/api/organizations.go +++ b/internal/api/organizations.go @@ -878,10 +878,10 @@ func writeOrganizationProfile(w http.ResponseWriter, profile *store.Organization } func addOrganizationIDParameter(operation *huma.Operation) { - operation.Parameters = append(operation.Parameters, &huma.Param{Name: "id", In: "path", Required: true, Description: "Organization ID", Schema: &huma.Schema{Type: huma.TypeInteger, Format: formatInt64}}) + operation.Parameters = append(operation.Parameters, &huma.Param{Name: "id", In: pathKey, Required: true, Description: "Organization ID", Schema: &huma.Schema{Type: huma.TypeInteger, Format: formatInt64}}) } func addOrganizationIfMatchParameter(operation *huma.Operation) { - operation.Parameters = append(operation.Parameters, &huma.Param{Name: ifMatchHeaderName, In: "header", Required: true, Description: "Strong ETag returned by the latest organization read", Schema: &huma.Schema{Type: huma.TypeString}}) + operation.Parameters = append(operation.Parameters, &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, Description: "Strong ETag returned by the latest organization read", Schema: &huma.Schema{Type: huma.TypeString}}) } func addOrganizationETagHeader(response *huma.Response) { response.Headers = map[string]*huma.Param{etagHeaderName: {Description: "Strong organization revision tag for optimistic concurrency", Schema: &huma.Schema{Type: huma.TypeString}}} diff --git a/internal/api/person_attributes.go b/internal/api/person_attributes.go index 106dcbcbe..e58379ed9 100644 --- a/internal/api/person_attributes.go +++ b/internal/api/person_attributes.go @@ -94,7 +94,7 @@ func (s *Server) registerPersonAttributeRoutes(api huma.API) { func addAttributeSlugParameter(operation *huma.Operation) { operation.Parameters = append(operation.Parameters, &huma.Param{ - Name: "slug", In: "path", Required: true, + Name: "slug", In: pathKey, Required: true, Description: "Immutable attribute definition slug", Schema: &huma.Schema{Type: huma.TypeString}, }) diff --git a/internal/api/person_merges.go b/internal/api/person_merges.go new file mode 100644 index 000000000..5d9c60647 --- /dev/null +++ b/internal/api/person_merges.go @@ -0,0 +1,563 @@ +package api + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/danielgtaylor/huma/v2" + "go.kenn.io/msgvault/internal/store" +) + +const ( + idempotencyKeyHeaderName = "Idempotency-Key" + newPersonETagOpenAPIHeaderName = "X-New-Person-ETag" + apiPersonMergeActor = "user" +) + +var newPersonETagHeaderName = http.CanonicalHeaderKey(newPersonETagOpenAPIHeaderName) + +type MergePersonRequest struct { + AbsorbedPersonID int64 `json:"absorbed_person_id"` +} + +type SplitPersonRequest struct { + MergeID int64 `json:"merge_id"` + ParticipantIDs []int64 `json:"participant_ids"` +} + +type DecidePersonMergeCandidateRequest struct { + PersonID int64 `json:"person_id"` + Decision store.PersonMergeCandidateDecision `json:"decision" enum:"accept,reject"` +} + +type PersonMergesResponse struct { + Merges []store.PersonMergeSummary `json:"merges"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type PersonMergeRequiredError struct { + Error string `json:"error"` + Message string `json:"message"` + Profiles []PersonMergeProfile `json:"profiles"` +} + +type PersonMergeProfile struct { + Person store.Person `json:"person"` + ETag string `json:"etag"` +} + +func (s *Server) registerPersonMergeRoutes(api huma.API) { + merge := rawAPIV1Operation("mergePersons", http.MethodPost, "/people/{id}/merge", + "Merge one durable person profile into another") + addPersonIDParameter(&merge) + addPersonMergeIfMatchParameter(&merge) + addIdempotencyKeyParameter(&merge) + merge.RequestBody = jsonRequestBodyFor[MergePersonRequest](api) + merge.Responses = jsonResponsesFor[store.PersonMergeResult](api) + addPersonETagHeader(merge.Responses[httpStatusKey(http.StatusOK)]) + addErrorResponses(api, merge.Responses, http.StatusBadRequest, http.StatusConflict, + http.StatusNotFound, http.StatusPreconditionRequired, http.StatusInternalServerError, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, merge, s.handleMergePersons) + + split := rawAPIV1Operation("splitPersonMerge", http.MethodPost, "/people/{id}/split", + "Split absorbed participant lineage into a new person") + addPersonIDParameter(&split) + addPersonIfMatchParameter(&split) + addIdempotencyKeyParameter(&split) + split.RequestBody = jsonRequestBodyFor[SplitPersonRequest](api) + split.Responses = jsonResponsesFor[store.PersonSplitResult](api) + addPersonETagHeader(split.Responses[httpStatusKey(http.StatusOK)]) + addNewPersonETagHeader(split.Responses[httpStatusKey(http.StatusOK)]) + addErrorResponses(api, split.Responses, http.StatusBadRequest, http.StatusConflict, + http.StatusNotFound, http.StatusPreconditionRequired, http.StatusInternalServerError, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, split, s.handleSplitPersonMerge) + + list := rawAPIV1Operation("listPersonMerges", http.MethodGet, "/people/{id}/merges", + "List merge history for a durable person") + addPersonIDParameter(&list) + list.Parameters = append(list.Parameters, + queryIntegerParam("limit", "Maximum results"), + queryIntegerParam("offset", "Results to skip")) + list.Responses = jsonResponsesFor[PersonMergesResponse](api) + addErrorResponses(api, list.Responses, http.StatusNotFound, http.StatusInternalServerError, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, list, s.handleListPersonMerges) + + detail := rawAPIV1Operation("getPersonMerge", http.MethodGet, "/person-merges/{merge_id}", + "Inspect one durable person merge") + addMergeIDParameter(&detail) + detail.Responses = jsonResponsesFor[store.PersonMergeDetail](api) + addErrorResponses(api, detail.Responses, http.StatusBadRequest, http.StatusNotFound, + http.StatusInternalServerError, http.StatusServiceUnavailable) + registerRawHumaRoute(api, detail, s.handleGetPersonMerge) + + snapshot := rawAPIV1Operation("getPersonMergeSnapshot", http.MethodGet, + "/person-merges/{merge_id}/snapshot", "Read and verify one person merge snapshot") + addMergeIDParameter(&snapshot) + snapshot.Responses = jsonResponsesFor[store.PersonMergeSnapshotResponse](api) + addNoStoreHeader(snapshot.Responses[httpStatusKey(http.StatusOK)]) + addErrorResponses(api, snapshot.Responses, http.StatusBadRequest, http.StatusNotFound, + http.StatusInternalServerError, http.StatusServiceUnavailable) + registerRawHumaRoute(api, snapshot, s.handleGetPersonMergeSnapshot) + + decision := rawAPIV1Operation("decidePersonMergeCandidate", http.MethodPost, + "/person-merge-candidates/{candidate_id}/decision", + "Accept or reject a person merge attribute candidate") + addCandidateIDParameter(&decision) + addPersonIfMatchParameter(&decision) + decision.RequestBody = jsonRequestBodyFor[DecidePersonMergeCandidateRequest](api) + decision.Responses = jsonResponsesFor[store.PersonMergeReviewCandidate](api) + addPersonETagHeader(decision.Responses[httpStatusKey(http.StatusOK)]) + addErrorResponses(api, decision.Responses, http.StatusBadRequest, http.StatusConflict, + http.StatusNotFound, http.StatusPreconditionRequired, http.StatusInternalServerError, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, decision, s.handleDecidePersonMergeCandidate) +} + +func (s *Server) handleMergePersons(w http.ResponseWriter, r *http.Request) { + profiles, ok := s.personProfileStore(w) + if !ok { + return + } + survivorID, ok := personProfileID(w, r) + if !ok { + return + } + var body MergePersonRequest + if !decodeEntityRequest(w, r, &body, "person merge") { + return + } + if body.AbsorbedPersonID <= 0 || body.AbsorbedPersonID == survivorID { + writeError(w, http.StatusBadRequest, "person_merge_invalid", + "absorbed_person_id must name a different positive person ID") + return + } + revisions, ok := personMergeIfMatch(w, r, survivorID, body.AbsorbedPersonID) + if !ok { + return + } + idempotencyKey, ok := personOperationIdempotencyKey(w, r) + if !ok { + return + } + result, err := profiles.MergePersonsContext(r.Context(), store.PersonMergeRequest{ + SurvivorID: survivorID, AbsorbedID: body.AbsorbedPersonID, + ExpectedSurvivorRevision: revisions[survivorID], + ExpectedAbsorbedRevision: revisions[body.AbsorbedPersonID], + IdempotencyKey: idempotencyKey, Actor: apiPersonMergeActor, + }) + if err != nil { + s.writePersonMergeError(w, err) + return + } + result.CacheState = s.refreshIdentityCacheState(r.Context()) + w.Header().Set(etagHeaderName, personETag(result.Person)) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) handleSplitPersonMerge(w http.ResponseWriter, r *http.Request) { + profiles, ok := s.personProfileStore(w) + if !ok { + return + } + sourceID, ok := personProfileID(w, r) + if !ok { + return + } + var body SplitPersonRequest + if !decodeEntityRequest(w, r, &body, "person split") { + return + } + revision, ok := personIfMatch(w, r, sourceID) + if !ok { + return + } + idempotencyKey, ok := personOperationIdempotencyKey(w, r) + if !ok { + return + } + result, err := profiles.SplitPersonMergeContext(r.Context(), store.PersonSplitRequest{ + SourcePersonID: sourceID, MergeID: body.MergeID, + ParticipantIDs: body.ParticipantIDs, ExpectedSourceRevision: revision, + IdempotencyKey: idempotencyKey, Actor: apiPersonMergeActor, + }) + if err != nil { + s.writePersonMergeError(w, err) + return + } + result.CacheState = s.refreshIdentityCacheState(r.Context()) + w.Header().Set(etagHeaderName, personETag(result.SourcePerson)) + w.Header().Set(newPersonETagHeaderName, personETag(result.NewPerson)) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) handleListPersonMerges(w http.ResponseWriter, r *http.Request) { + profiles, ok := s.personProfileStore(w) + if !ok { + return + } + personID, ok := personProfileID(w, r) + if !ok { + return + } + limit, _, err := queryInt(r, "limit") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_limit", "limit must be an integer") + return + } + if limit <= 0 { + limit = 100 + } + limit = min(limit, 500) + offset, _, err := queryInt(r, "offset") + if err != nil || offset < 0 { + writeError(w, http.StatusBadRequest, "invalid_offset", "offset must be a non-negative integer") + return + } + merges, err := profiles.ListPersonMergesPageContext(r.Context(), personID, limit, offset) + if err != nil { + s.writePersonMergeError(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, PersonMergesResponse{Merges: merges, Limit: limit, Offset: offset}) +} + +func (s *Server) handleGetPersonMerge(w http.ResponseWriter, r *http.Request) { + profiles, ok := s.personProfileStore(w) + if !ok { + return + } + mergeID, ok := personMergePositivePathID(w, r, "merge_id", "invalid_merge_id", "Merge ID") + if !ok { + return + } + detail, err := profiles.GetPersonMergeContext(r.Context(), mergeID) + if err != nil { + s.writePersonMergeError(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, detail) +} + +func (s *Server) handleGetPersonMergeSnapshot(w http.ResponseWriter, r *http.Request) { + profiles, ok := s.personProfileStore(w) + if !ok { + return + } + mergeID, ok := personMergePositivePathID(w, r, "merge_id", "invalid_merge_id", "Merge ID") + if !ok { + return + } + snapshot, err := profiles.GetPersonMergeSnapshotContext(r.Context(), mergeID) + if err != nil { + s.writePersonMergeError(w, err) + return + } + digest := sha256.Sum256(snapshot.JSON) + if hex.EncodeToString(digest[:]) != snapshot.SHA256 { + s.writePersonMergeError(w, store.ErrPersonMergeSnapshotCorrupt) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", applicationJSONMediaType) + writeJSON(w, http.StatusOK, snapshot) +} + +func (s *Server) handleDecidePersonMergeCandidate(w http.ResponseWriter, r *http.Request) { + profiles, ok := s.personProfileStore(w) + if !ok { + return + } + candidateID, ok := personMergePositivePathID( + w, r, "candidate_id", "invalid_candidate_id", "Candidate ID", + ) + if !ok { + return + } + var body DecidePersonMergeCandidateRequest + if !decodeEntityRequest(w, r, &body, "person merge candidate") { + return + } + if body.PersonID <= 0 { + writeError(w, http.StatusBadRequest, "person_merge_invalid", "person_id must be positive") + return + } + revision, ok := personIfMatch(w, r, body.PersonID) + if !ok { + return + } + decision, err := profiles.DecidePersonMergeCandidateContext( + r.Context(), store.PersonMergeCandidateDecisionRequest{ + CandidateID: candidateID, PersonID: body.PersonID, + ExpectedPersonRevision: revision, Decision: body.Decision, + Actor: apiPersonMergeActor, + }, + ) + if err != nil { + s.writePersonMergeError(w, err) + return + } + w.Header().Set(etagHeaderName, personRevisionETag(body.PersonID, decision.PersonRevision)) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, decision.PersonMergeReviewCandidate) +} + +func (s *Server) writePersonMergeError(w http.ResponseWriter, err error) { + if s.writeIfContextError(w, err) { + return + } + switch { + case errors.Is(err, store.ErrPersonNotFound): + writeError(w, http.StatusNotFound, "person_profile_not_found", "Person profile not found") + case errors.Is(err, store.ErrPersonMergeNotFound), errors.Is(err, store.ErrPersonSplitNotFound): + writeError(w, http.StatusNotFound, "person_merge_not_found", "Person merge not found") + case errors.Is(err, store.ErrPersonRevisionConflict), errors.Is(err, store.ErrPersonSplitRevision): + writeError(w, http.StatusConflict, "person_merge_revision_conflict", + "Person profile changed; reload and retry") + case errors.Is(err, store.ErrPersonMergeIdempotency): + writeError(w, http.StatusConflict, "person_merge_idempotency_conflict", + "Idempotency key was already used for a different merge") + case errors.Is(err, store.ErrPersonSplitIdempotency): + writeError(w, http.StatusConflict, "person_split_idempotency_conflict", + "Idempotency key was already used for a different split") + case errors.Is(err, store.ErrPersonMergeAlreadySplit): + writeError(w, http.StatusConflict, "person_merge_already_split", + "The selected merge lineage has already been split") + case errors.Is(err, store.ErrPersonSplitReviewed): + writeError(w, http.StatusConflict, "person_split_reviewed_candidates", + "Exact reversal is unavailable after a merge review candidate was accepted") + case errors.Is(err, store.ErrPersonSplitOwnership): + writeError(w, http.StatusConflict, "person_split_merge_not_owned", + "The selected merge is no longer owned by this person") + case errors.Is(err, store.ErrPersonCardDAVPublished): + writeError(w, http.StatusConflict, "person_carddav_published", + "Unpublish the person profiles before merging them") + case errors.Is(err, store.ErrPersonMergeCandidateNotFound): + writeError(w, http.StatusNotFound, "person_merge_candidate_not_found", + "Person merge candidate not found") + case errors.Is(err, store.ErrPersonMergeCandidateState): + writeError(w, http.StatusConflict, "person_merge_candidate_state_changed", + "The candidate or current person value changed; reload and retry") + case errors.Is(err, store.ErrPersonSplitParticipants): + writeError(w, http.StatusBadRequest, "person_split_invalid_participants", err.Error()) + case errors.Is(err, store.ErrPersonMergeInvalid): + writeError(w, http.StatusBadRequest, "person_merge_invalid", err.Error()) + case errors.Is(err, store.ErrPersonMergeSnapshotCorrupt): + s.logger.Error("person merge snapshot integrity failure", "error", err) + writeError(w, http.StatusInternalServerError, "person_merge_snapshot_corrupt", + "Person merge snapshot failed integrity verification") + default: + s.logger.Error("person merge operation failed", "error", err) + writeError(w, http.StatusInternalServerError, "person_merge_failed", + "Person merge operation failed") + } +} + +func (s *Server) writePersonMergeRequired( + w http.ResponseWriter, r *http.Request, err error, +) bool { + var conflict *store.PersonBindingConflictError + if !errors.As(err, &conflict) { + return false + } + personIDs := append([]int64(nil), conflict.PersonIDs...) + slices.Sort(personIDs) + personIDs = slices.Compact(personIDs) + if len(personIDs) != 2 { + return false + } + profiles, ok := s.store.(PersonProfileStore) + if !ok { + return false + } + + response := PersonMergeRequiredError{ + Error: "person_merge_required", + Message: "The identity clusters belong to different person profiles; merge one profile before retrying", + Profiles: make([]PersonMergeProfile, 0, len(personIDs)), + } + for _, personID := range personIDs { + person, loadErr := profiles.GetPersonContext(r.Context(), personID) + if loadErr != nil { + s.logger.Error("load person merge conflict profile", "person_id", personID, "error", loadErr) + writeError(w, http.StatusInternalServerError, "person_merge_failed", + "Person merge profiles could not be loaded") + return true + } + response.Profiles = append(response.Profiles, PersonMergeProfile{ + Person: *person, + ETag: personETag(*person), + }) + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusConflict, response) + return true +} + +func personMergeConflictResponseFor(api huma.API) *huma.Response { + return &huma.Response{ + Description: http.StatusText(http.StatusConflict), + Content: map[string]*huma.MediaType{ + applicationJSONMediaType: { + Schema: &huma.Schema{AnyOf: []*huma.Schema{ + schemaFor[PersonMergeRequiredError](api), + schemaFor[ErrorResponse](api), + }}, + }, + }, + } +} + +func personMergeIfMatch( + w http.ResponseWriter, r *http.Request, survivorID, absorbedID int64, +) (map[int64]int64, bool) { + values := r.Header.Values(ifMatchHeaderName) + if len(values) == 0 { + writeError(w, http.StatusPreconditionRequired, "if_match_required", "If-Match is required") + return nil, false + } + tags := []string{} + for _, value := range values { + for tag := range strings.SplitSeq(value, ",") { + tags = append(tags, strings.TrimSpace(tag)) + } + } + if len(tags) != 2 { + writeError(w, http.StatusBadRequest, "invalid_if_match", + "If-Match must contain exactly two strong person revision tags") + return nil, false + } + revisions := make(map[int64]int64, 2) + for _, tag := range tags { + id, revision, ok := parsePersonETag(tag) + if !ok || (id != survivorID && id != absorbedID) { + writeError(w, http.StatusBadRequest, "invalid_if_match", + "If-Match must contain one strong revision tag for each person") + return nil, false + } + if _, duplicate := revisions[id]; duplicate { + writeError(w, http.StatusBadRequest, "invalid_if_match", + "If-Match contains a duplicate person revision tag") + return nil, false + } + revisions[id] = revision + } + if len(revisions) != 2 { + writeError(w, http.StatusBadRequest, "invalid_if_match", + "If-Match must contain one strong revision tag for each person") + return nil, false + } + return revisions, true +} + +func parsePersonETag(value string) (int64, int64, bool) { + if len(value) < len(`"person-1-r1"`) || value[0] != '"' || value[len(value)-1] != '"' { + return 0, 0, false + } + inner := value[1 : len(value)-1] + if !strings.HasPrefix(inner, "person-") { + return 0, 0, false + } + parts := strings.Split(strings.TrimPrefix(inner, "person-"), "-r") + if len(parts) != 2 { + return 0, 0, false + } + id, idErr := strconv.ParseInt(parts[0], 10, 64) + revision, revisionErr := strconv.ParseInt(parts[1], 10, 64) + return id, revision, idErr == nil && revisionErr == nil && id > 0 && revision > 0 +} + +func personOperationIdempotencyKey(w http.ResponseWriter, r *http.Request) (string, bool) { + values := r.Header.Values(idempotencyKeyHeaderName) + if len(values) == 0 || (len(values) == 1 && strings.TrimSpace(values[0]) == "") { + writeError(w, http.StatusPreconditionRequired, "idempotency_key_required", + "Idempotency-Key is required") + return "", false + } + if len(values) != 1 { + writeError(w, http.StatusBadRequest, "invalid_idempotency_key", + "Idempotency-Key must contain exactly one value") + return "", false + } + value := strings.TrimSpace(values[0]) + if len(value) > 128 { + writeError(w, http.StatusBadRequest, "invalid_idempotency_key", + "Idempotency-Key must be at most 128 bytes") + return "", false + } + return value, true +} + +func personMergePositivePathID( + w http.ResponseWriter, r *http.Request, name, code, label string, +) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue(name), 10, 64) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, code, label+" must be a positive integer") + return 0, false + } + return id, true +} + +func addPersonMergeIfMatchParameter(operation *huma.Operation) { + operation.Parameters = append(operation.Parameters, &huma.Param{ + Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Exactly two comma-separated strong person revision tags, one for each profile", + Schema: &huma.Schema{Type: huma.TypeString}, + }) +} + +func addIdempotencyKeyParameter(operation *huma.Operation) { + operation.Parameters = append(operation.Parameters, &huma.Param{ + Name: idempotencyKeyHeaderName, In: headerParamLocation, Required: true, + Description: "Opaque 1..128-byte retry key", + Schema: &huma.Schema{Type: huma.TypeString, MinLength: new(1), MaxLength: new(128)}, + }) +} + +func addMergeIDParameter(operation *huma.Operation) { + addPositiveInt64PathParameter(operation, "merge_id", "Durable person merge ID") +} + +func addCandidateIDParameter(operation *huma.Operation) { + addPositiveInt64PathParameter(operation, "candidate_id", "Person merge review candidate ID") +} + +func addPositiveInt64PathParameter(operation *huma.Operation, name, description string) { + operation.Parameters = append(operation.Parameters, &huma.Param{ + Name: name, In: pathKey, Required: true, Description: description, + Schema: &huma.Schema{Type: huma.TypeInteger, Format: formatInt64, Minimum: new(float64(1))}, + }) +} + +func addNewPersonETagHeader(response *huma.Response) { + if response.Headers == nil { + response.Headers = map[string]*huma.Param{} + } + response.Headers[newPersonETagOpenAPIHeaderName] = &huma.Param{ + Description: "Strong revision tag for the new person created by a split", + Schema: &huma.Schema{Type: huma.TypeString}, + } +} + +func addNoStoreHeader(response *huma.Response) { + if response.Headers == nil { + response.Headers = map[string]*huma.Param{} + } + response.Headers["Cache-Control"] = &huma.Param{ + Description: "Always no-store because the response contains merge provenance", + Schema: &huma.Schema{Type: huma.TypeString}, + } +} diff --git a/internal/api/person_merges_test.go b/internal/api/person_merges_test.go new file mode 100644 index 000000000..5b4568efe --- /dev/null +++ b/internal/api/person_merges_test.go @@ -0,0 +1,462 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +type corruptPersonMergeSnapshotStore struct { + *stubIdentityCacheStore +} + +type atomicCandidateDecisionStore struct { + *stubIdentityCacheStore + + result *store.PersonMergeCandidateDecisionResult + getPersonCalls int +} + +func (s *atomicCandidateDecisionStore) DecidePersonMergeCandidateContext( + _ context.Context, _ store.PersonMergeCandidateDecisionRequest, +) (*store.PersonMergeCandidateDecisionResult, error) { + return s.result, nil +} + +func (s *atomicCandidateDecisionStore) GetPersonContext( + _ context.Context, _ int64, +) (*store.Person, error) { + s.getPersonCalls++ + return nil, store.ErrPersonNotFound +} + +func (s *corruptPersonMergeSnapshotStore) GetPersonMergeSnapshotContext( + _ context.Context, _ int64, +) (*store.PersonMergeSnapshotResponse, error) { + return &store.PersonMergeSnapshotResponse{ + Version: 1, + SHA256: strings.Repeat("0", 64), + JSON: json.RawMessage(`{"version":1}`), + }, nil +} + +func TestPersonMergeHTTPMergeInspectDecideAndSplit(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := t.Context() + srv, st := newIdentityLinkTestServer(t) + survivorParticipant := st.mustParticipant(t, + "merge-api-survivor@example.com", "Merge Survivor", "example.com") + absorbedParticipant := st.mustParticipant(t, + "merge-api-absorbed@example.com", "Merge Absorbed", "example.com") + survivor, _, err := st.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := st.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + for personID, value := range map[int64]string{ + survivor.ID: "email", absorbed.ID: "chat", + } { + _, err = st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: &value}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + + mergedResponse := personMergeAPIRequest(t, srv, http.MethodPost, + fmt.Sprintf("/api/v1/people/%d/merge", survivor.ID), + fmt.Appendf(nil, `{"absorbed_person_id":%d}`, absorbed.ID), + map[string]string{ + "If-Match": fmt.Sprintf(`%s, %s`, personETag(*survivor), personETag(*absorbed)), + "Idempotency-Key": "merge-api-operation", + }) + require.Equal(http.StatusOK, mergedResponse.Code, mergedResponse.Body.String()) + var merged struct { + store.PersonMergeResult + + IdentityRevision int64 `json:"identity_revision"` + CacheState string `json:"cache_state"` + } + require.NoError(json.Unmarshal(mergedResponse.Body.Bytes(), &merged)) + assert.Equal(survivor.ID, merged.Person.ID) + require.Len(merged.ReviewCandidates, 1) + assert.Positive(merged.IdentityRevision) + assert.Equal(identityCacheStateReady, merged.CacheState) + assert.Equal(1, st.refreshCalls) + assert.Equal(personETag(merged.Person), mergedResponse.Header().Get("ETag")) + assert.Equal("no-store", mergedResponse.Header().Get("Cache-Control")) + + listResponse := personMergeAPIRequest(t, srv, http.MethodGet, + fmt.Sprintf("/api/v1/people/%d/merges", survivor.ID), nil, nil) + require.Equal(http.StatusOK, listResponse.Code, listResponse.Body.String()) + var listed struct { + Merges []store.PersonMergeSummary `json:"merges"` + } + require.NoError(json.Unmarshal(listResponse.Body.Bytes(), &listed)) + require.Len(listed.Merges, 1) + assert.Equal(merged.Merge.ID, listed.Merges[0].Merge.ID) + + detailResponse := personMergeAPIRequest(t, srv, http.MethodGet, + fmt.Sprintf("/api/v1/person-merges/%d", merged.Merge.ID), nil, nil) + require.Equal(http.StatusOK, detailResponse.Code, detailResponse.Body.String()) + var detail store.PersonMergeDetail + require.NoError(json.Unmarshal(detailResponse.Body.Bytes(), &detail)) + assert.Equal(merged.Merge.ID, detail.Merge.ID) + assert.NotEmpty(detail.Rows) + + snapshotResponse := personMergeAPIRequest(t, srv, http.MethodGet, + fmt.Sprintf("/api/v1/person-merges/%d/snapshot", merged.Merge.ID), nil, nil) + require.Equal(http.StatusOK, snapshotResponse.Code, snapshotResponse.Body.String()) + assert.Equal("no-store", snapshotResponse.Header().Get("Cache-Control")) + assert.Equal("application/json", snapshotResponse.Header().Get("Content-Type")) + var snapshot store.PersonMergeSnapshotResponse + require.NoError(json.Unmarshal(snapshotResponse.Body.Bytes(), &snapshot)) + assert.Equal(merged.Merge.SnapshotSHA256, snapshot.SHA256) + assert.NotEmpty(snapshot.JSON) + + decisionResponse := personMergeAPIRequest(t, srv, http.MethodPost, + fmt.Sprintf("/api/v1/person-merge-candidates/%d/decision", merged.ReviewCandidates[0].ID), + fmt.Appendf(nil, `{"person_id":%d,"decision":"reject"}`, merged.Person.ID), + map[string]string{"If-Match": personETag(merged.Person)}) + require.Equal(http.StatusOK, decisionResponse.Code, decisionResponse.Body.String()) + var decided store.PersonMergeReviewCandidate + require.NoError(json.Unmarshal(decisionResponse.Body.Bytes(), &decided)) + assert.Equal("rejected", decided.State) + assert.NotEqual(personETag(merged.Person), decisionResponse.Header().Get("ETag")) + + current, err := st.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + st.refreshErr = errors.New("cache refresh failed") + splitResponse := personMergeAPIRequest(t, srv, http.MethodPost, + fmt.Sprintf("/api/v1/people/%d/split", current.ID), + fmt.Appendf(nil, `{"merge_id":%d,"participant_ids":[%d]}`, + merged.Merge.ID, absorbedParticipant), + map[string]string{ + "If-Match": personETag(*current), "Idempotency-Key": "split-api-operation", + }) + require.Equal(http.StatusOK, splitResponse.Code, splitResponse.Body.String()) + var split struct { + store.PersonSplitResult + + IdentityRevision int64 `json:"identity_revision"` + CacheState string `json:"cache_state"` + } + require.NoError(json.Unmarshal(splitResponse.Body.Bytes(), &split)) + assert.True(split.ExactReversal) + assert.Greater(split.IdentityRevision, merged.IdentityRevision) + assert.Equal(identityCacheStateStale, split.CacheState) + assert.Equal(2, st.refreshCalls) + assert.Equal(personETag(split.SourcePerson), splitResponse.Header().Get("ETag")) + assert.Equal(personETag(split.NewPerson), splitResponse.Header().Get(newPersonETagHeaderName)) +} + +func TestPersonMergeHTTPCandidateDecisionUsesAtomicRevision(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + wrapped := &atomicCandidateDecisionStore{ + stubIdentityCacheStore: &stubIdentityCacheStore{Store: testutil.NewTestStore(t)}, + result: &store.PersonMergeCandidateDecisionResult{ + ID: 17, + PersonID: 23, + State: "rejected", + PersonRevision: 42, + }, + } + srv := NewServer(&config.Config{Server: config.ServerConfig{APIPort: 8080}}, + wrapped, nil, testLogger()) + response := personMergeAPIRequest(t, srv, http.MethodPost, + "/api/v1/person-merge-candidates/17/decision", + []byte(`{"person_id":23,"decision":"reject"}`), + map[string]string{"If-Match": `"person-23-r41"`}) + + require.Equal(http.StatusOK, response.Code, response.Body.String()) + assert.Equal(`"person-23-r42"`, response.Header().Get("ETag")) + assert.Zero(wrapped.getPersonCalls) + var candidate store.PersonMergeReviewCandidate + require.NoError(json.Unmarshal(response.Body.Bytes(), &candidate)) + assert.Equal(int64(17), candidate.ID) + assert.Equal("rejected", candidate.State) +} + +func TestPersonMergeHTTPPreconditionsAndTypedErrors(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + srv, st := newIdentityLinkTestServer(t) + firstParticipant := st.mustParticipant(t, + "merge-precondition-a@example.com", "Merge A", "example.com") + secondParticipant := st.mustParticipant(t, + "merge-precondition-b@example.com", "Merge B", "example.com") + first, _, err := st.CreatePersonFromParticipant(firstParticipant) + require.NoError(err) + second, _, err := st.CreatePersonFromParticipant(secondParticipant) + require.NoError(err) + path := fmt.Sprintf("/api/v1/people/%d/merge", first.ID) + body := fmt.Appendf(nil, `{"absorbed_person_id":%d}`, second.ID) + validTags := fmt.Sprintf(`%s, %s`, personETag(*first), personETag(*second)) + + tests := []struct { + name string + headers map[string]string + wantStatus int + wantCode string + }{ + {name: "missing If-Match", headers: map[string]string{"Idempotency-Key": "missing-tags"}, + wantStatus: http.StatusPreconditionRequired, wantCode: "if_match_required"}, + {name: "missing idempotency", headers: map[string]string{"If-Match": validTags}, + wantStatus: http.StatusPreconditionRequired, wantCode: "idempotency_key_required"}, + {name: "one tag", headers: map[string]string{ + "If-Match": personETag(*first), "Idempotency-Key": "one-tag"}, + wantStatus: http.StatusBadRequest, wantCode: "invalid_if_match"}, + {name: "weak tag", headers: map[string]string{ + "If-Match": "W/" + validTags, "Idempotency-Key": "weak-tag"}, + wantStatus: http.StatusBadRequest, wantCode: "invalid_if_match"}, + {name: "wildcard", headers: map[string]string{ + "If-Match": "*, " + personETag(*second), "Idempotency-Key": "wildcard"}, + wantStatus: http.StatusBadRequest, wantCode: "invalid_if_match"}, + {name: "duplicate tag", headers: map[string]string{ + "If-Match": personETag(*first) + ", " + personETag(*first), + "Idempotency-Key": "duplicate-tag"}, + wantStatus: http.StatusBadRequest, wantCode: "invalid_if_match"}, + {name: "unrelated tag", headers: map[string]string{ + "If-Match": personETag(*first) + `, "person-999-r1"`, + "Idempotency-Key": "unrelated-tag"}, + wantStatus: http.StatusBadRequest, wantCode: "invalid_if_match"}, + {name: "oversized idempotency", headers: map[string]string{ + "If-Match": validTags, "Idempotency-Key": strings.Repeat("x", 129)}, + wantStatus: http.StatusBadRequest, wantCode: "invalid_idempotency_key"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := personMergeAPIRequest(t, srv, http.MethodPost, path, body, test.headers) + assert.Equal(test.wantStatus, response.Code, response.Body.String()) + var apiErr ErrorResponse + require.NoError(json.Unmarshal(response.Body.Bytes(), &apiErr)) + assert.Equal(test.wantCode, apiErr.Error) + }) + } + + stale := personMergeAPIRequest(t, srv, http.MethodPost, path, body, + map[string]string{ + "If-Match": fmt.Sprintf(`"person-%d-r%d", %s`, first.ID, first.Revision+1, personETag(*second)), + "Idempotency-Key": "stale-merge", + }) + assert.Equal(http.StatusConflict, stale.Code, stale.Body.String()) + var apiErr ErrorResponse + require.NoError(json.Unmarshal(stale.Body.Bytes(), &apiErr)) + assert.Equal("person_merge_revision_conflict", apiErr.Error) + + missingMerge := personMergeAPIRequest(t, srv, http.MethodGet, + "/api/v1/person-merges/999999", nil, nil) + assert.Equal(http.StatusNotFound, missingMerge.Code) +} + +func TestPersonMergeOpenAPIContract(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + document := OpenAPIDocument() + paths := map[string]string{ + "/api/v1/people/{id}/merge": http.MethodPost, + "/api/v1/people/{id}/split": http.MethodPost, + "/api/v1/people/{id}/merges": http.MethodGet, + "/api/v1/person-merges/{merge_id}": http.MethodGet, + "/api/v1/person-merges/{merge_id}/snapshot": http.MethodGet, + "/api/v1/person-merge-candidates/{candidate_id}/decision": http.MethodPost, + } + for path, method := range paths { + item := document.Paths[path] + require.NotNil(item, path) + var operation *huma.Operation + switch method { + case http.MethodGet: + operation = item.Get + case http.MethodPost: + operation = item.Post + } + require.NotNil(operation, path) + } + + merge := document.Paths["/api/v1/people/{id}/merge"].Post + assert.True(requiredHeaderParameter(merge, "If-Match")) + assert.True(requiredHeaderParameter(merge, "Idempotency-Key")) + assert.Contains(merge.Parameters[1].Description, "Exactly two") + assert.Equal("#/components/schemas/MergePersonRequest", + merge.RequestBody.Content[applicationJSONMediaType].Schema.Ref) + assert.Equal("#/components/schemas/PersonMergeResult", + merge.Responses[httpStatusKey(http.StatusOK)].Content[applicationJSONMediaType].Schema.Ref) + require.Contains(merge.Responses[httpStatusKey(http.StatusOK)].Headers, etagHeaderName) + split := document.Paths["/api/v1/people/{id}/split"].Post + assert.True(requiredHeaderParameter(split, "If-Match")) + assert.True(requiredHeaderParameter(split, "Idempotency-Key")) + assert.Equal("#/components/schemas/SplitPersonRequest", + split.RequestBody.Content[applicationJSONMediaType].Schema.Ref) + assert.Equal("#/components/schemas/PersonSplitResult", + split.Responses[httpStatusKey(http.StatusOK)].Content[applicationJSONMediaType].Schema.Ref) + require.Contains(split.Responses[httpStatusKey(http.StatusOK)].Headers, etagHeaderName) + require.Contains(split.Responses[httpStatusKey(http.StatusOK)].Headers, + newPersonETagOpenAPIHeaderName) + clientDocument := openAPIClientDocument() + for schemaName, properties := range map[string][]string{ + "PersonMergeDetail": {"participants", "review_candidates", "rows", "splits"}, + "PersonMergeResult": {"review_candidates"}, + "PersonSplitResult": {"ambiguous_rows"}, + } { + for _, propertyName := range properties { + property := clientDocument.Components.Schemas.Map()[schemaName].Properties[propertyName] + require.NotNil(property, schemaName+"."+propertyName) + assert.Equal(false, property.Extensions["x-omitempty"], schemaName+"."+propertyName) + } + } + + expectedResponses := map[string]string{ + "/api/v1/people/{id}/merges": "#/components/schemas/PersonMergesResponse", + "/api/v1/person-merges/{merge_id}": "#/components/schemas/PersonMergeDetail", + "/api/v1/person-merges/{merge_id}/snapshot": "#/components/schemas/PersonMergeSnapshotResponse", + } + for path, wantRef := range expectedResponses { + operation := document.Paths[path].Get + assert.Equal(wantRef, + operation.Responses[httpStatusKey(http.StatusOK)].Content[applicationJSONMediaType].Schema.Ref) + } + snapshot := document.Paths["/api/v1/person-merges/{merge_id}/snapshot"].Get + require.Contains(snapshot.Responses[httpStatusKey(http.StatusOK)].Headers, "Cache-Control") + decision := document.Paths["/api/v1/person-merge-candidates/{candidate_id}/decision"].Post + assert.Equal("#/components/schemas/DecidePersonMergeCandidateRequest", + decision.RequestBody.Content[applicationJSONMediaType].Schema.Ref) + assert.Equal("#/components/schemas/PersonMergeReviewCandidate", + decision.Responses[httpStatusKey(http.StatusOK)].Content[applicationJSONMediaType].Schema.Ref) +} + +func TestPersonMergeRequiredOpenAPIContract(t *testing.T) { + require := require.New(t) + document := OpenAPIDocument() + operations := []*huma.Operation{ + document.Paths["/api/v1/identity/links"].Post, + document.Paths["/api/v1/identity/match-candidates/{id}/accept"].Post, + } + for _, operation := range operations { + require.NotNil(operation) + conflict := operation.Responses[httpStatusKey(http.StatusConflict)] + require.NotNil(conflict) + media := conflict.Content[applicationJSONMediaType] + require.NotNil(media) + require.Empty(media.Schema.OneOf) + require.Len(media.Schema.AnyOf, 2) + assert.Equal(t, "#/components/schemas/PersonMergeRequiredError", media.Schema.AnyOf[0].Ref) + assert.Equal(t, "#/components/schemas/ErrorResponse", media.Schema.AnyOf[1].Ref) + } +} + +func TestPersonMergeHTTPErrorMapping(t *testing.T) { + srv, _ := newIdentityLinkTestServer(t) + tests := []struct { + name string + err error + wantStatus int + wantCode string + }{ + {name: "invalid merge", err: store.ErrPersonMergeInvalid, + wantStatus: http.StatusBadRequest, wantCode: "person_merge_invalid"}, + {name: "missing merge", err: store.ErrPersonMergeNotFound, + wantStatus: http.StatusNotFound, wantCode: "person_merge_not_found"}, + {name: "merge revision", err: store.ErrPersonRevisionConflict, + wantStatus: http.StatusConflict, wantCode: "person_merge_revision_conflict"}, + {name: "split revision", err: store.ErrPersonSplitRevision, + wantStatus: http.StatusConflict, wantCode: "person_merge_revision_conflict"}, + {name: "merge retry", err: store.ErrPersonMergeIdempotency, + wantStatus: http.StatusConflict, wantCode: "person_merge_idempotency_conflict"}, + {name: "split retry", err: store.ErrPersonSplitIdempotency, + wantStatus: http.StatusConflict, wantCode: "person_split_idempotency_conflict"}, + {name: "split reviewed", err: store.ErrPersonSplitReviewed, + wantStatus: http.StatusConflict, wantCode: "person_split_reviewed_candidates"}, + {name: "split participants", err: store.ErrPersonSplitParticipants, + wantStatus: http.StatusBadRequest, wantCode: "person_split_invalid_participants"}, + {name: "candidate state", err: store.ErrPersonMergeCandidateState, + wantStatus: http.StatusConflict, wantCode: "person_merge_candidate_state_changed"}, + {name: "missing candidate", err: store.ErrPersonMergeCandidateNotFound, + wantStatus: http.StatusNotFound, wantCode: "person_merge_candidate_not_found"}, + {name: "snapshot", err: store.ErrPersonMergeSnapshotCorrupt, + wantStatus: http.StatusInternalServerError, wantCode: "person_merge_snapshot_corrupt"}, + {name: "internal", err: errors.New("private driver detail"), + wantStatus: http.StatusInternalServerError, wantCode: "person_merge_failed"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + response := httptest.NewRecorder() + srv.writePersonMergeError(response, test.err) + assert.Equal(test.wantStatus, response.Code) + var apiErr ErrorResponse + require.NoError(json.Unmarshal(response.Body.Bytes(), &apiErr)) + assert.Equal(test.wantCode, apiErr.Error) + assert.NotContains(response.Body.String(), "private driver detail") + }) + } +} + +func TestPersonMergeHTTPSnapshotRejectsHashMismatch(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + base := &stubIdentityCacheStore{Store: testutil.NewTestStore(t)} + srv := NewServer( + &config.Config{Server: config.ServerConfig{APIPort: 8080}}, + &corruptPersonMergeSnapshotStore{stubIdentityCacheStore: base}, + nil, + testLogger(), + ) + + response := personMergeAPIRequest(t, srv, http.MethodGet, + "/api/v1/person-merges/1/snapshot", nil, nil) + assert.Equal(http.StatusInternalServerError, response.Code) + var apiErr ErrorResponse + require.NoError(json.Unmarshal(response.Body.Bytes(), &apiErr)) + assert.Equal("person_merge_snapshot_corrupt", apiErr.Error) + assert.NotContains(response.Body.String(), `{"version":1}`) +} + +func requiredHeaderParameter(operation *huma.Operation, name string) bool { + for _, parameter := range operation.Parameters { + if parameter.In == "header" && parameter.Name == name { + return parameter.Required + } + } + return false +} + +func personMergeAPIRequest( + t *testing.T, + srv *Server, + method, path string, + body []byte, + headers map[string]string, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + for key, value := range headers { + request.Header.Set(key, value) + } + response := httptest.NewRecorder() + srv.Router().ServeHTTP(response, request) + return response +} diff --git a/internal/api/person_profiles.go b/internal/api/person_profiles.go index d291b09fa..a777d7062 100644 --- a/internal/api/person_profiles.go +++ b/internal/api/person_profiles.go @@ -34,6 +34,16 @@ type PersonProfileStore interface { ) (*store.Person, error) DeletePersonContext(ctx context.Context, id, expectedRevision int64) error PersonForParticipantsContext(ctx context.Context, participantIDs []int64) (*store.Person, error) + MergePersonsContext(ctx context.Context, request store.PersonMergeRequest) (*store.PersonMergeResult, error) + SplitPersonMergeContext(ctx context.Context, request store.PersonSplitRequest) (*store.PersonSplitResult, error) + ListPersonMergesPageContext( + ctx context.Context, personID int64, limit, offset int, + ) ([]store.PersonMergeSummary, error) + GetPersonMergeContext(ctx context.Context, mergeID int64) (*store.PersonMergeDetail, error) + GetPersonMergeSnapshotContext(ctx context.Context, mergeID int64) (*store.PersonMergeSnapshotResponse, error) + DecidePersonMergeCandidateContext( + ctx context.Context, request store.PersonMergeCandidateDecisionRequest, + ) (*store.PersonMergeCandidateDecisionResult, error) } type CreatePersonRequest struct { @@ -346,6 +356,9 @@ func (s *Server) writePersonError(w http.ResponseWriter, err error) { case errors.Is(err, store.ErrPersonCardDAVPublished): writeError(w, http.StatusConflict, "person_carddav_published", "Unpublish this person from CardDAV before deleting it") + case errors.Is(err, store.ErrPersonMergeActive): + writeError(w, http.StatusConflict, "person_merge_active", + "Split the person's active merge lineage before deleting this profile") case errors.Is(err, store.ErrParticipantNotFound), errors.Is(err, store.ErrInvalidParticipantID): writeError(w, http.StatusBadRequest, "invalid_participant_id", err.Error()) default: @@ -365,14 +378,14 @@ func writePerson(w http.ResponseWriter, status int, person *store.Person) { func addPersonIDParameter(operation *huma.Operation) { operation.Parameters = append(operation.Parameters, &huma.Param{ - Name: "id", In: "path", Required: true, Description: "Durable person ID", + Name: "id", In: pathKey, Required: true, Description: "Durable person ID", Schema: &huma.Schema{Type: huma.TypeInteger, Format: formatInt64}, }) } func addPersonIfMatchParameter(operation *huma.Operation) { operation.Parameters = append(operation.Parameters, &huma.Param{ - Name: ifMatchHeaderName, In: "header", Required: true, + Name: ifMatchHeaderName, In: headerParamLocation, Required: true, Description: "Strong ETag returned by the latest person profile read. " + "Must be the exact single tag from that read; the RFC 7232 forms `*` " + "and comma-separated tag lists are not supported.", @@ -399,7 +412,11 @@ func personProfileID(w http.ResponseWriter, r *http.Request) (int64, bool) { } func personETag(person store.Person) string { - return fmt.Sprintf(`"person-%d-r%d"`, person.ID, person.Revision) + return personRevisionETag(person.ID, person.Revision) +} + +func personRevisionETag(personID, revision int64) string { + return fmt.Sprintf(`"person-%d-r%d"`, personID, revision) } func personIfMatch(w http.ResponseWriter, r *http.Request, id int64) (int64, bool) { diff --git a/internal/api/person_relationships.go b/internal/api/person_relationships.go index 9b360ca90..744528c54 100644 --- a/internal/api/person_relationships.go +++ b/internal/api/person_relationships.go @@ -508,10 +508,10 @@ func writePersonRelationship(w http.ResponseWriter, status int, edge *store.Pers } func addRelationshipIDParameter(operation *huma.Operation, description string) { - operation.Parameters = append(operation.Parameters, &huma.Param{Name: "id", In: "path", Required: true, Description: description, Schema: &huma.Schema{Type: huma.TypeInteger, Format: formatInt64}}) + operation.Parameters = append(operation.Parameters, &huma.Param{Name: "id", In: pathKey, Required: true, Description: description, Schema: &huma.Schema{Type: huma.TypeInteger, Format: formatInt64}}) } func addRelationshipIfMatchParameter(operation *huma.Operation, subject string) { - operation.Parameters = append(operation.Parameters, &huma.Param{Name: ifMatchHeaderName, In: "header", Required: true, Description: "Strong ETag returned by the latest " + subject + " read", Schema: &huma.Schema{Type: huma.TypeString}}) + operation.Parameters = append(operation.Parameters, &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, Description: "Strong ETag returned by the latest " + subject + " read", Schema: &huma.Schema{Type: huma.TypeString}}) } func addRelationshipTypeHeaders(response *huma.Response, location bool) { response.Headers = map[string]*huma.Param{etagHeaderName: {Description: "Strong relationship type revision tag for optimistic concurrency", Schema: &huma.Schema{Type: huma.TypeString}}} diff --git a/internal/api/routes.go b/internal/api/routes.go index 974d3e95c..e3e6a4400 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -136,7 +136,7 @@ func (s *Server) setupHumaAPI(mux humago.Mux) huma.API { config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ apiKeySecurityScheme: { Type: "apiKey", - In: "header", + In: headerParamLocation, Name: "X-Api-Key", }, } @@ -232,6 +232,7 @@ func (s *Server) registerHumaRoutes(api huma.API, apiV1 huma.API) { s.registerDocumentSearchRoute(apiV1) s.registerPersonProfileRoutes(apiV1) s.registerPersonTrackingRoutes(apiV1) + s.registerPersonMergeRoutes(apiV1) s.registerOrganizationRoutes(apiV1) s.registerEmploymentRoutes(apiV1) s.registerActivityRoutes(apiV1) @@ -682,7 +683,7 @@ func rawRouteParameters(operationID string) []*huma.Param { case "listMessageTasks", "createOrLinkMessageTask": params := []*huma.Param{pathIntegerParam("Archived email message ID")} if operationID == "createOrLinkMessageTask" { - params = append(params, param("X-Request-Id", "header", "string", "Browser-generated retry-stable request ID", true)) + params = append(params, param("X-Request-Id", headerParamLocation, "string", "Browser-generated retry-stable request ID", true)) } return params case "listIdentityMatchCandidates": @@ -980,7 +981,7 @@ func mergeParams(groups ...[]*huma.Param) []*huma.Param { } func pathStringParam(name, doc string) *huma.Param { - return param(name, "path", huma.TypeString, doc, true) + return param(name, pathKey, huma.TypeString, doc, true) } func pathIntegerParam(doc string) *huma.Param { @@ -988,7 +989,7 @@ func pathIntegerParam(doc string) *huma.Param { } func pathNamedIntegerParam(name, doc string) *huma.Param { - p := param(name, "path", huma.TypeInteger, doc, true) + p := param(name, pathKey, huma.TypeInteger, doc, true) p.Schema.Format = formatInt64 return p } diff --git a/internal/api/saved_views.go b/internal/api/saved_views.go index ff6af6ca6..08c5c86a0 100644 --- a/internal/api/saved_views.go +++ b/internal/api/saved_views.go @@ -98,14 +98,14 @@ func (s *Server) registerSavedViewRoutes(api huma.API) { func addSavedViewIDParameter(operation *huma.Operation) { operation.Parameters = append(operation.Parameters, &huma.Param{ - Name: "id", In: "path", Required: true, Description: "Saved View ID", + Name: "id", In: pathKey, Required: true, Description: "Saved View ID", Schema: &huma.Schema{Type: huma.TypeInteger, Format: formatInt64}, }) } func addSavedViewIfMatchParameter(operation *huma.Operation) { operation.Parameters = append(operation.Parameters, &huma.Param{ - Name: ifMatchHeaderName, In: "header", Required: true, + Name: ifMatchHeaderName, In: headerParamLocation, Required: true, Description: "Strong ETag returned by the latest Saved View read", Schema: &huma.Schema{Type: huma.TypeString}, }) diff --git a/internal/api/server.go b/internal/api/server.go index e6a698809..5073d2bb6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1117,7 +1117,7 @@ func (s *Server) loggerMiddleware(next http.Handler) http.Handler { stopWatch() s.logger.Info("http request", "method", r.Method, - "path", r.URL.Path, + pathKey, r.URL.Path, "status", ww.Status(), "bytes", ww.BytesWritten(), "duration", time.Since(start), @@ -1151,7 +1151,7 @@ func (s *Server) watchInProgressRequest(r *http.Request, start time.Time) func() case <-timer.C: s.logger.Warn("http request in progress", "method", method, - "path", path, + pathKey, path, "request_id", requestID, "elapsed", time.Since(start), ) @@ -1173,7 +1173,7 @@ func (s *Server) recoverMiddleware(next http.Handler) http.Handler { if recovered := recover(); recovered != nil { s.logger.Error("panic serving request", "panic", recovered, - "path", r.URL.Path, + pathKey, r.URL.Path, "request_id", requestIDFromContext(r.Context()), ) if !ww.WroteHeader() { @@ -1283,7 +1283,7 @@ func (s *Server) loopbackRateLimitExempt(r *http.Request) bool { func (s *Server) logUnauthorizedAPIRequest(r *http.Request) { s.logger.Warn("unauthorized API request", - "path", r.URL.Path, + pathKey, r.URL.Path, "remote_addr", r.RemoteAddr, ) } diff --git a/internal/api/settings.go b/internal/api/settings.go index f03f712f6..356d4260d 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -170,7 +170,7 @@ func (s *Server) registerSettingsRoutes(api huma.API) { patch := rawAPIV1Operation("patchSettings", http.MethodPatch, "/settings", "Update browser-managed settings") patch.Parameters = append(patch.Parameters, &huma.Param{ Name: ifMatchHeaderName, - In: "header", + In: headerParamLocation, Description: "Strong ETag returned by the latest settings read", Required: true, Schema: &huma.Schema{Type: huma.TypeString}, diff --git a/internal/store/activity.go b/internal/store/activity.go index c9e1984ba..438bbac23 100644 --- a/internal/store/activity.go +++ b/internal/store/activity.go @@ -2242,6 +2242,188 @@ func (s *Store) recomputeContactStateTx( first, last, inbound, outbound, int64(len(evidence))) } +func (s *Store) reconcilePersonActivityStateTx( + ctx context.Context, + tx *loggedTx, + survivorID, absorbedID int64, + revisions ContactRevisions, +) error { + contactIDs, err := s.reclassifyPersonActivityTx( + ctx, tx, []int64{survivorID, absorbedID}, revisions, + ) + if err != nil { + return err + } + var absorbedContactExists bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM person_contact_state WHERE person_id = ? + )`, absorbedID).Scan(&absorbedContactExists); err != nil { + return fmt.Errorf("inspect absorbed contact state: %w", err) + } + if absorbedContactExists { + if _, err := tx.ExecContext(ctx, + `DELETE FROM person_contact_state WHERE person_id = ?`, absorbedID, + ); err != nil { + return fmt.Errorf("delete absorbed contact state: %w", err) + } + } + for _, personID := range contactIDs { + if personID == absorbedID { + continue + } + if err := s.recomputeContactStateTx( + ctx, tx, personID, revisions, true, + ); err != nil { + return err + } + } + return nil +} + +// reclassifyPersonActivityTx starts from the affected people and indexed +// participant/message edges. It never constructs an archive-wide counterpart +// relation. Events whose classification did not change keep their old epoch; +// the bounded projector backstop advances that stamp after the identity write. +func (s *Store) reclassifyPersonActivityTx( + ctx context.Context, tx *loggedTx, personIDs []int64, revisions ContactRevisions, +) ([]int64, error) { + if len(personIDs) == 0 { + return nil, nil + } + placeholders := personMergeSnapshotPlaceholders(len(personIDs)) + personArgs := personMergeSnapshotIDArgs(personIDs) + args := make([]any, 0, len(personIDs)*4) + for range 4 { + args = append(args, personArgs...) + } + messageIDs, err := personMergeRowIDsTx(ctx, tx, `WITH affected_messages AS ( + SELECT message.id AS message_id + FROM person_participants binding + JOIN messages message ON message.sender_id = binding.participant_id + WHERE binding.person_id IN (`+placeholders+`) + UNION + SELECT recipient.message_id + FROM person_participants binding + JOIN message_recipients recipient ON recipient.participant_id = binding.participant_id + WHERE binding.person_id IN (`+placeholders+`) + UNION + SELECT message.id + FROM person_participants binding + JOIN conversation_participants member ON member.participant_id = binding.participant_id + JOIN messages message ON message.conversation_id = member.conversation_id + WHERE binding.person_id IN (`+placeholders+`) + UNION + SELECT link.message_id + FROM activity_event_persons link + WHERE link.person_id IN (`+placeholders+`) + ) + SELECT event.message_id + FROM affected_messages affected + JOIN activity_events event ON event.message_id = affected.message_id + GROUP BY event.message_id + ORDER BY event.message_id`, args...) + if err != nil { + return nil, fmt.Errorf("load person-affected activity messages: %w", err) + } + if err := s.lockActivityMessagesTx(ctx, tx, "person activity messages", messageIDs); err != nil { + return nil, err + } + candidates, err := s.loadActivityCandidatesByIDQueryerContext(ctx, tx, messageIDs) + if err != nil { + return nil, fmt.Errorf("load person activity candidates: %w", err) + } + type activityRewrite struct { + old *ActivityEvent + next *ActivityEvent + } + rewrites := make([]activityRewrite, 0, len(candidates)) + contactPeople := make(map[int64]struct{}, len(personIDs)) + for _, personID := range personIDs { + contactPeople[personID] = struct{}{} + } + for _, candidate := range candidates { + old, err := s.loadActivityEventTx(ctx, tx, candidate.MessageID) + if err != nil { + return nil, err + } + if old == nil { + continue + } + for _, personID := range directActivityPersons(old) { + contactPeople[personID] = struct{}{} + } + if !candidate.Eligible { + rewrites = append(rewrites, activityRewrite{old: old}) + continue + } + classification := ClassifyActivityCandidate( + candidate, candidate.DirectLimitTransition.Target) + next := *old + next.RefKind = classification.RefKind + next.Channel = classification.Channel + next.Direction = classification.Direction + next.OwnerSourceID = classification.OwnerSourceID + next.OwnerAddress = classification.OwnerAddress + next.ProjectedIdentityRevision = revisions.IdentityRevision + next.ProjectedAccountIdentityRevision = revisions.AccountIdentityRevision + next.Persons = classification.Persons + for _, personID := range directActivityPersons(&next) { + contactPeople[personID] = struct{}{} + } + if activityEventClassificationEqual(old, &next) { + continue + } + rewrites = append(rewrites, activityRewrite{old: old, next: &next}) + } + contactIDs := sortedInt64Set(contactPeople) + if err := s.lockActivityContactPersonsTx(ctx, tx, contactIDs); err != nil { + return nil, err + } + if len(rewrites) > 0 { + if err := s.lockActivityProjectionQueueFreshnessTx(ctx, tx); err != nil { + return nil, err + } + } + for _, rewrite := range rewrites { + if rewrite.next == nil { + if _, err := tx.ExecContext(ctx, + `DELETE FROM activity_events WHERE message_id = ?`, rewrite.old.MessageID, + ); err != nil { + return nil, fmt.Errorf("retract person activity event: %w", err) + } + continue + } + if err := s.replaceActivityEventTx(ctx, tx, *rewrite.next); err != nil { + return nil, err + } + } + return contactIDs, nil +} + +func activityEventClassificationEqual(left, right *ActivityEvent) bool { + if left.RefKind != right.RefKind || left.Channel != right.Channel || + left.Direction != right.Direction || + !sameOptionalInt64(left.OwnerSourceID, right.OwnerSourceID) || + left.OwnerAddress != right.OwnerAddress { + return false + } + return slices.Equal(sortedActivityPersons(left.Persons), sortedActivityPersons(right.Persons)) +} + +func (s *Store) lockActivityMessagesTx( + ctx context.Context, tx *loggedTx, label string, messageIDs []int64, +) error { + for start := 0; start < len(messageIDs); start += activityCandidateIDChunk { + chunk := messageIDs[start:min(start+activityCandidateIDChunk, len(messageIDs))] + if err := s.lockRowsTx(ctx, tx, `SELECT id FROM messages + WHERE id IN (`+int64Placeholders(len(chunk))+`) ORDER BY id`, + label, 1, chunk); err != nil { + return err + } + } + return nil +} + func (s *Store) writeRecomputedContactStateTx( ctx context.Context, tx *loggedTx, diff --git a/internal/store/activity_classify.go b/internal/store/activity_classify.go new file mode 100644 index 000000000..7d4e0a579 --- /dev/null +++ b/internal/store/activity_classify.go @@ -0,0 +1,264 @@ +package store + +import "slices" + +const DefaultMaxDirectActivityCounterparts = 25 + +// ActivityClassification is the owner-relative interpretation of one native +// activity candidate. It lives in store so identity mutations can rebuild +// affected rows inside their authoritative transaction. +type ActivityClassification struct { + RefKind ActivityRefKind + Channel ActivityChannel + Direction ActivityDirection + OwnerSourceID *int64 + OwnerAddress string + Persons []ActivityEventPerson +} + +// ClassifyActivityCandidate applies the same pure rules used by the external +// activity projector. Keeping one implementation prevents inline identity +// repairs from drifting from incremental and backstop projection. +func ClassifyActivityCandidate( + candidate ActivityCandidate, maxDirectCounterparts int, +) ActivityClassification { + if maxDirectCounterparts <= 0 { + maxDirectCounterparts = DefaultMaxDirectActivityCounterparts + } + counterparts := collapseActivityCounterparts(candidate.Counterparts) + meeting := IsMeetingMessageType(candidate.MessageType) + result := ActivityClassification{ + RefKind: RefKindMessage, + Channel: activityChannelFor(candidate.ConversationType, meeting), + } + if meeting { + result.RefKind = RefKindMeeting + } + + owningSenderIndex := -1 + for index, counterpart := range counterparts { + if counterpart.RecipientType == "from" && counterpart.IsOwner { + owningSenderIndex = index + break + } + } + switch { + case candidate.SourceIsFromMe || owningSenderIndex >= 0: + result.Direction = DirectionOutbound + if owningSenderIndex >= 0 { + result.OwnerAddress = counterparts[owningSenderIndex].OwnerAddress + } + case activityOwnerAmongNonSenders(counterparts): + result.Direction = DirectionInbound + result.OwnerAddress = firstActivityNonSenderOwnerAddress(counterparts) + default: + result.Direction = DirectionObserved + } + if result.Direction != DirectionObserved && candidate.SourceID > 0 { + sourceID := candidate.SourceID + result.OwnerSourceID = &sourceID + } + + ownerParticipants := make(map[int64]struct{}, len(counterparts)) + ownerPersons := make(map[int64]struct{}, len(counterparts)) + for _, counterpart := range counterparts { + if !counterpart.IsOwner { + continue + } + ownerParticipants[counterpart.ParticipantID] = struct{}{} + if counterpart.PersonID != nil { + ownerPersons[*counterpart.PersonID] = struct{}{} + } + } + ownerLinked := func(counterpart ActivityCounterpart) bool { + if counterpart.IsOwner { + return true + } + if _, owned := ownerParticipants[counterpart.ParticipantID]; owned { + return true + } + if counterpart.PersonID == nil { + return false + } + _, owned := ownerPersons[*counterpart.PersonID] + return owned + } + + type audienceKey struct { + person bool + id int64 + } + nonOwnerIDs := make(map[audienceKey]struct{}, len(counterparts)) + for _, counterpart := range counterparts { + if ownerLinked(counterpart) { + continue + } + key := audienceKey{id: counterpart.ParticipantID} + if counterpart.PersonID != nil { + key = audienceKey{person: true, id: *counterpart.PersonID} + } + nonOwnerIDs[key] = struct{}{} + } + broadcast := len(nonOwnerIDs) > maxDirectCounterparts + for _, counterpart := range counterparts { + if ownerLinked(counterpart) || counterpart.PersonID == nil { + continue + } + isSender := counterpart.RecipientType == "from" + result.Persons = append(result.Persons, ActivityEventPerson{ + PersonID: *counterpart.PersonID, + Role: classifiedActivityRole(counterpart.RecipientType, isSender, meeting), + Evidence: classifiedActivityEvidence(result.Direction, isSender, broadcast), + }) + } + result.Persons = strongestClassifiedActivityLinks(result.Persons) + return result +} + +func collapseActivityCounterparts(counterparts []ActivityCounterpart) []ActivityCounterpart { + if len(counterparts) < 2 { + return counterparts + } + type key struct { + participantID int64 + recipientType string + } + seen := make(map[key]int, len(counterparts)) + result := make([]ActivityCounterpart, 0, len(counterparts)) + for _, counterpart := range counterparts { + k := key{participantID: counterpart.ParticipantID, recipientType: counterpart.RecipientType} + index, found := seen[k] + if !found { + seen[k] = len(result) + result = append(result, counterpart) + continue + } + current := &result[index] + if current.PersonID == nil && counterpart.PersonID != nil { + personID := *counterpart.PersonID + current.PersonID = &personID + } + if counterpart.IsOwner { + current.IsOwner = true + if current.OwnerAddress == "" || + (counterpart.OwnerAddress != "" && counterpart.OwnerAddress < current.OwnerAddress) { + current.OwnerAddress = counterpart.OwnerAddress + } + } + } + return result +} + +func activityChannelFor(conversationType string, meeting bool) ActivityChannel { + if meeting { + return ChannelMeeting + } + switch conversationType { + case "email_thread": + return ChannelEmail + case "group_chat", "direct_chat", "channel": + return ChannelChat + default: + return ChannelOther + } +} + +func activityOwnerAmongNonSenders(counterparts []ActivityCounterpart) bool { + for _, counterpart := range counterparts { + if counterpart.RecipientType != "from" && counterpart.IsOwner { + return true + } + } + return false +} + +func firstActivityNonSenderOwnerAddress(counterparts []ActivityCounterpart) string { + for _, counterpart := range counterparts { + if counterpart.RecipientType != "from" && counterpart.IsOwner { + return counterpart.OwnerAddress + } + } + return "" +} + +func classifiedActivityRole(recipientType string, sender, meeting bool) ActivityRole { + switch { + case sender && meeting: + return RoleOrganizer + case sender: + return RoleSender + case recipientType == "member": + return RoleMember + case meeting: + return RoleAttendee + default: + return RoleAddressed + } +} + +func classifiedActivityEvidence( + direction ActivityDirection, sender, broadcast bool, +) ActivityEvidence { + switch direction { + case DirectionOutbound: + if !broadcast { + return EvidenceDirect + } + case DirectionInbound: + if sender { + return EvidenceDirect + } + case DirectionObserved: + } + return EvidenceCoPresence +} + +func strongestClassifiedActivityLinks(links []ActivityEventPerson) []ActivityEventPerson { + strongest := make(map[int64]ActivityEventPerson, len(links)) + for _, link := range links { + current, found := strongest[link.PersonID] + if !found || strongerClassifiedActivityLink(link, current) { + strongest[link.PersonID] = link + } + } + result := make([]ActivityEventPerson, 0, len(strongest)) + for _, link := range strongest { + result = append(result, link) + } + slices.SortFunc(result, func(left, right ActivityEventPerson) int { + switch { + case left.PersonID < right.PersonID: + return -1 + case left.PersonID > right.PersonID: + return 1 + default: + return 0 + } + }) + return result +} + +func strongerClassifiedActivityLink(candidate, current ActivityEventPerson) bool { + if candidate.Evidence != current.Evidence { + return candidate.Evidence == EvidenceDirect + } + return classifiedActivityRolePriority(candidate.Role) < + classifiedActivityRolePriority(current.Role) +} + +func classifiedActivityRolePriority(role ActivityRole) int { + switch role { + case RoleSender: + return 0 + case RoleOrganizer: + return 1 + case RoleAddressed: + return 2 + case RoleAttendee: + return 3 + case RoleMember: + return 4 + default: + return 5 + } +} diff --git a/internal/store/activity_columns.go b/internal/store/activity_columns.go index c540851e8..25fc5c132 100644 --- a/internal/store/activity_columns.go +++ b/internal/store/activity_columns.go @@ -25,7 +25,7 @@ import "strings" // TestMessagesActivityColumnsAreRealColumns keeps this list honest against the // live table. var MessagesActivityColumns = []string{ - "source_id", // owner source; immutable in production, listed as read + sourceIDColumnName, // owner source; immutable in production, listed as read "conversation_id", // routing key; co-presence and conversation_type "sender_id", // direct-counterpart and direction derivation "message_type", // channel classification (email/chat/meeting) diff --git a/internal/store/activity_queries.go b/internal/store/activity_queries.go index 171759df6..8462270f9 100644 --- a/internal/store/activity_queries.go +++ b/internal/store/activity_queries.go @@ -238,6 +238,14 @@ func (s *Store) ScanAllActivityCandidatesContext( func (s *Store) LoadActivityCandidatesByIDContext( ctx context.Context, messageIDs []int64, +) ([]ActivityCandidate, error) { + return s.loadActivityCandidatesByIDQueryerContext(ctx, s.db, messageIDs) +} + +func (s *Store) loadActivityCandidatesByIDQueryerContext( + ctx context.Context, + queryer contextRowsQuerier, + messageIDs []int64, ) ([]ActivityCandidate, error) { unique := make(map[int64]struct{}, len(messageIDs)) for _, messageID := range messageIDs { @@ -255,13 +263,19 @@ func (s *Store) LoadActivityCandidatesByIDContext( sorted = append(sorted, messageID) } slices.Sort(sorted) - placeholders := strings.TrimSuffix(strings.Repeat("?,", len(sorted)), ",") - args := make([]any, len(sorted)) - for index, messageID := range sorted { - args[index] = messageID - } - rows, err := s.db.QueryContext(ctx, s.dialect.Rebind( - activityCandidateStateCTE+` + candidates := make([]ActivityCandidate, 0, len(sorted)) + for start := 0; start < len(sorted); start += activityCandidateIDChunk { + if err := ctx.Err(); err != nil { + return nil, err + } + chunk := sorted[start:min(start+activityCandidateIDChunk, len(sorted))] + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(chunk)), ",") + args := make([]any, len(chunk)) + for index, messageID := range chunk { + args[index] = messageID + } + rows, err := queryer.QueryContext(ctx, s.dialect.Rebind( + activityCandidateStateCTE+` SELECT `+activityCandidateColumns+`, CASE WHEN q.message_id IS NULL THEN 0 ELSE 1 END AS queue_exists, COALESCE(q.revision, 0), COALESCE(q.processed_revision, 0) @@ -271,15 +285,17 @@ func (s *Store) LoadActivityCandidatesByIDContext( CROSS JOIN activity_current_state r WHERE m.id IN (`+placeholders+`) ORDER BY m.id - `), args...) - if err != nil { - return nil, fmt.Errorf("load exact activity candidates: %w", err) - } - candidates, err := scanActivityCandidateRows(rows) - if err != nil { - return nil, err + `), args...) + if err != nil { + return nil, fmt.Errorf("load exact activity candidates: %w", err) + } + loaded, err := scanActivityCandidateRows(rows) + if err != nil { + return nil, err + } + candidates = append(candidates, loaded...) } - return s.attachActivityCounterpartsContext(ctx, candidates) + return s.attachActivityCounterpartsQueryerContext(ctx, queryer, candidates) } func scanActivityCandidateRows(rows rowsScanner) ([]ActivityCandidate, error) { @@ -364,6 +380,11 @@ func activityUsableTime(value *time.Time) bool { return value != nil && !value.IsZero() } +// activityCandidateIDChunk bounds exact candidate loads and the row locks used +// by merge/split activity reconciliation. Keeping both paths on the same +// ascending chunks avoids backend parameter limits without changing lock order. +const activityCandidateIDChunk = 512 + // activityCounterpartIDChunk bounds the ID list of one counterpart query. // The query repeats the list four times and SQLite caps bound variables at // 32,766, so the projector's maximum batch (10,000 candidates) must attach @@ -373,6 +394,14 @@ const activityCounterpartIDChunk = 512 func (s *Store) attachActivityCounterpartsContext( ctx context.Context, candidates []ActivityCandidate, +) ([]ActivityCandidate, error) { + return s.attachActivityCounterpartsQueryerContext(ctx, s.db, candidates) +} + +func (s *Store) attachActivityCounterpartsQueryerContext( + ctx context.Context, + queryer contextRowsQuerier, + candidates []ActivityCandidate, ) ([]ActivityCandidate, error) { if len(candidates) == 0 { return candidates, nil @@ -387,7 +416,7 @@ func (s *Store) attachActivityCounterpartsContext( for start := 0; start < len(messageIDs); start += activityCounterpartIDChunk { end := min(start+activityCounterpartIDChunk, len(messageIDs)) if err := s.attachActivityCounterpartChunkContext( - ctx, byMessageID, messageIDs[start:end], + ctx, queryer, byMessageID, messageIDs[start:end], ); err != nil { return nil, err } @@ -397,6 +426,7 @@ func (s *Store) attachActivityCounterpartsContext( func (s *Store) attachActivityCounterpartChunkContext( ctx context.Context, + queryer contextRowsQuerier, byMessageID map[int64]*ActivityCandidate, messageIDs []any, ) error { @@ -573,7 +603,7 @@ func (s *Store) attachActivityCounterpartChunkContext( args = append(args, messageIDs...) args = append(args, messageIDs...) args = append(args, messageIDs...) - rows, err := s.db.QueryContext(ctx, s.dialect.Rebind(query), args...) + rows, err := queryer.QueryContext(ctx, s.dialect.Rebind(query), args...) if err != nil { return fmt.Errorf("load activity counterparts: %w", err) } diff --git a/internal/store/activity_queries_test.go b/internal/store/activity_queries_test.go index 3006d3151..319563086 100644 --- a/internal/store/activity_queries_test.go +++ b/internal/store/activity_queries_test.go @@ -714,9 +714,8 @@ func TestSourceNativeOwnershipScopedToSender(t *testing.T) { } // TestAttachActivityCounterpartsChunksLargeBatches loads more candidates than -// one counterpart chunk (512 IDs) so the attach path exercises multiple -// chunked queries; the query repeats its ID list four times, and an unchunked -// maximum batch would exceed SQLite's bound-variable limit. +// one candidate and counterpart chunk (512 IDs), exercising both bounded +// queries in the public exact-load path. func TestAttachActivityCounterpartsChunksLargeBatches(t *testing.T) { require := require.New(t) f := storetest.New(t) diff --git a/internal/store/activity_test.go b/internal/store/activity_test.go index ff8c2beb4..f723cd482 100644 --- a/internal/store/activity_test.go +++ b/internal/store/activity_test.go @@ -12,7 +12,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.kenn.io/msgvault/internal/activity" "go.kenn.io/msgvault/internal/store" "go.kenn.io/msgvault/internal/testutil" "go.kenn.io/msgvault/internal/testutil/storetest" @@ -440,7 +439,7 @@ func TestConversationTypeMutationReopensEveryAffectedActivityCandidate(t *testin require.NotNil(candidate.ConversationID) assert.Equal(f.ConvID, *candidate.ConversationID) assert.Equal("group_chat", candidate.ConversationType) - assert.Equal(store.ChannelChat, activity.Classify(candidate, 25).Channel) + assert.Equal(store.ChannelChat, store.ClassifyActivityCandidate(candidate, 25).Channel) assert.Greater(candidate.Queue.Revision, candidate.Queue.ProcessedRevision) } } @@ -991,7 +990,7 @@ func TestActivityAttributionMatchesCanonicalMessageIdentityRules(t *testing.T) { sender := counterpart(candidates[0], "from", owner) assert.False(sender.IsOwner) assert.Equal(store.DirectionObserved, - activity.Classify(candidates[0], 25).Direction) + store.ClassifyActivityCandidate(candidates[0], 25).Direction) // An email-typed identifier is a legacy fallback only when the // participant has no primary email address. @@ -1030,7 +1029,7 @@ func TestActivityAttributionMatchesCanonicalMessageIdentityRules(t *testing.T) { assert.Len(candidates[0].Counterparts, 2) assert.True(counterpart(candidates[0], "from", owner).IsOwner) assert.Equal(store.DirectionOutbound, - activity.Classify(candidates[0], 1).Direction) + store.ClassifyActivityCandidate(candidates[0], 1).Direction) } func TestLoadActivityCandidateUsesRecipientsBeforeConversationMembers(t *testing.T) { diff --git a/internal/store/attribute_definitions.go b/internal/store/attribute_definitions.go index 9f5b85925..4d11e4c14 100644 --- a/internal/store/attribute_definitions.go +++ b/internal/store/attribute_definitions.go @@ -113,7 +113,7 @@ var attributeFieldTypes = map[AttributeFieldType]bool{ AttributeFieldPhone: true, AttributeFieldJSON: true, } -var attributeRecordTargets = map[string]bool{"person": true} +var attributeRecordTargets = map[string]bool{string(AttributeObjectPerson): true} var attributeSlugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) diff --git a/internal/store/backup_test.go b/internal/store/backup_test.go index e065973db..2abc3249c 100644 --- a/internal/store/backup_test.go +++ b/internal/store/backup_test.go @@ -96,3 +96,53 @@ func TestBackupDatabaseContext_CancellationRemovesUnpublishedBackup(t *testing.T require.NoError(globErr, "glob temporary backups after cancellation") assert.Empty(tempMatches, "temporary backup files must be cleaned up") } + +func TestBackupDatabaseContext_PreservesReversiblePersonMerge(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + testutil.SkipIfPostgres(t, "VACUUM INTO backup publication is SQLite-only") + ctx := context.Background() + f := storetest.New(t) + survivorParticipant := f.EnsureParticipant( + "backup-merge-survivor@example.com", "Survivor", "example.com") + absorbedParticipant := f.EnsureParticipant( + "backup-merge-absorbed@example.com", "Absorbed", "example.com") + survivor, _, err := f.Store.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := f.Store.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + _, err = f.Store.AddPersonNameContext(ctx, absorbed.ID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Backup Absorbed"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + absorbed, err = f.Store.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "backup-person-merge", Actor: "test", + }) + require.NoError(err) + + destination := filepath.Join(t.TempDir(), "msgvault.db.backup") + require.NoError(f.Store.BackupDatabaseContext(ctx, destination)) + restored, err := store.Open(destination) + require.NoError(err) + t.Cleanup(func() { require.NoError(restored.Close()) }) + detail, err := restored.GetPersonMergeContext(ctx, merged.Merge.ID) + require.NoError(err) + assert.Len(detail.Participants, 2) + _, err = restored.GetPersonMergeSnapshotContext(ctx, merged.Merge.ID) + require.NoError(err) + split, err := restored.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: []int64{absorbedParticipant}, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "backup-person-split", Actor: "test", + }) + require.NoError(err) + assert.True(split.ExactReversal) + assert.Equal([]int64{absorbedParticipant}, split.NewPerson.ParticipantIDs) +} diff --git a/internal/store/contended_write.go b/internal/store/contended_write.go index 4781a8d79..da0682a00 100644 --- a/internal/store/contended_write.go +++ b/internal/store/contended_write.go @@ -71,6 +71,35 @@ func retryContendedWrite[T any]( operation, maxContendedWriteAttempts, lastErr) } +// retryBusyWrite retries only lock and transaction contention. Callers whose +// unique constraints describe deterministic domain conflicts use this form so +// they return the first typed failure instead of repeating an expensive write. +func retryBusyWrite[T any]( + ctx context.Context, s *Store, operation string, attempt func() (*T, error), +) (*T, error) { + var lastErr error + for i := range maxContendedWriteAttempts { + write, err := attempt() + if err == nil { + return write, nil + } + if !s.dialect.IsBusyError(err) { + return nil, err + } + lastErr = err + if i == maxContendedWriteAttempts-1 { + break + } + select { + case <-time.After(contendedWriteBackoff(i)): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return nil, fmt.Errorf("%s: gave up after %d attempts: %w", + operation, maxContendedWriteAttempts, lastErr) +} + // retryContendedWriteErr is retryContendedWrite for writers that return no // value. func retryContendedWriteErr( diff --git a/internal/store/content_columns.go b/internal/store/content_columns.go index 0f31be4d3..df066c52d 100644 --- a/internal/store/content_columns.go +++ b/internal/store/content_columns.go @@ -62,7 +62,7 @@ var MessagesContentColumns = []string{ // wrong call here is a consumer that silently misses updates. var MessagesNonContentColumns = []string{ "id", // immutable identity - "source_id", // immutable: which account this came from + sourceIDColumnName, // immutable: which account this came from "rfc822_message_id", // not reported by the feed (dedup.go rewrites it) "read_at", // local read state, not archive content "delivered_at", // platform delivery receipt diff --git a/internal/store/dialect_pg.go b/internal/store/dialect_pg.go index d181d31cf..ebbd2f443 100644 --- a/internal/store/dialect_pg.go +++ b/internal/store/dialect_pg.go @@ -616,8 +616,8 @@ func (d *PostgreSQLDialect) LegacyColumnMigrations() []ColumnMigration { {`ALTER TABLE participant_identifiers ADD COLUMN IF NOT EXISTS scope_value TEXT`, "pi_scope_value"}, {postgresParticipantLinkIdentityMatchCandidateMigration, "participant_links.identity_match_candidate_id"}, {postgresIdentityMatchObservationConflictOriginMigration, identityMatchObservationConflictOriginMigrationDesc}, - {postgresIdentityMatchCandidateSourcesMigration, "identity_match_candidate_sources"}, - {postgresIdentityMatchEvidenceSourcesMigration, "identity_match_evidence_sources"}, + {postgresIdentityMatchCandidateSourcesMigration, identityMatchCandidateSourcesTableName}, + {postgresIdentityMatchEvidenceSourcesMigration, identityMatchEvidenceSourcesTableName}, {postgresIdentityMatchPreConflictStateMigration, "identity_match_candidates.pre_conflict_state"}, {postgresIdentityMatchApplicationPendingMigration, "identity_match_candidates.application_pending"}, {`ALTER TABLE embedding_changes ADD COLUMN IF NOT EXISTS old_message_type TEXT`, "embedding_changes.old_message_type"}, @@ -1789,8 +1789,8 @@ var exclusiveLockTables = []string{ "sync_runs", "sources", "conversations", "conversation_participants", "messages", "message_recipients", "message_labels", "message_bodies", "message_raw", "attachments", "document_occurrences", "labels", "participants", "participant_identifiers", "reactions", - "participant_contact_observations", "identity_match_candidates", "identity_match_candidate_sources", - "identity_match_evidence", "identity_match_evidence_sources", + "participant_contact_observations", identityMatchCandidatesTableName, identityMatchCandidateSourcesTableName, + identityMatchEvidenceTableName, identityMatchEvidenceSourcesTableName, // persons and person_participants: MergeParticipants (reached from the // Beeper import path) repoints bindings and bumps person revisions, so // both belong to the sync/import write set this lock mirrors. diff --git a/internal/store/dialect_sqlite.go b/internal/store/dialect_sqlite.go index 09851534a..c545a6e4a 100644 --- a/internal/store/dialect_sqlite.go +++ b/internal/store/dialect_sqlite.go @@ -1571,8 +1571,8 @@ func (d *SQLiteDialect) LegacyColumnMigrations() []ColumnMigration { {`ALTER TABLE participant_identifiers ADD COLUMN scope_value TEXT`, "pi_scope_value"}, {sqliteParticipantLinkIdentityMatchCandidateMigration, "participant_links.identity_match_candidate_id"}, {sqliteIdentityMatchObservationConflictOriginMigration, identityMatchObservationConflictOriginMigrationDesc}, - {sqliteIdentityMatchCandidateSourcesMigration, "identity_match_candidate_sources"}, - {sqliteIdentityMatchEvidenceSourcesMigration, "identity_match_evidence_sources"}, + {sqliteIdentityMatchCandidateSourcesMigration, identityMatchCandidateSourcesTableName}, + {sqliteIdentityMatchEvidenceSourcesMigration, identityMatchEvidenceSourcesTableName}, {sqliteIdentityMatchPreConflictStateMigration, "identity_match_candidates.pre_conflict_state"}, {sqliteIdentityMatchApplicationPendingMigration, "identity_match_candidates.application_pending"}, {`ALTER TABLE embedding_changes ADD COLUMN old_message_type TEXT`, "embedding_changes.old_message_type"}, diff --git a/internal/store/export_test.go b/internal/store/export_test.go index c5a9c8beb..f82952173 100644 --- a/internal/store/export_test.go +++ b/internal/store/export_test.go @@ -125,3 +125,26 @@ func (s *Store) SetAttachmentRoleRepairPreparedHookForTest(fn func()) func() { s.attachmentRoleRepairPreparedHook = fn return func() { s.attachmentRoleRepairPreparedHook = nil } } + +// SetIdentityMatchAcceptBeforeDecisionHookForTest pauses a user acceptance +// after its initial read and before its locked decision transaction. +func (s *Store) SetIdentityMatchAcceptBeforeDecisionHookForTest(fn func()) func() { + s.identityMatchAcceptBeforeDecisionHook = fn + return func() { s.identityMatchAcceptBeforeDecisionHook = nil } +} + +// SetPersonOperationBeforeIdentityLockHookForTest installs a per-Store barrier +// immediately before merge and split transactions acquire the identity lock. +// Concurrency tests use it to prove every competing transaction is open and at +// the lock boundary before either is released. +func (s *Store) SetPersonOperationBeforeIdentityLockHookForTest(fn func()) func() { + s.personOperationBeforeIdentityLockHook = fn + return func() { s.personOperationBeforeIdentityLockHook = nil } +} + +// SetPersonMergeAfterSnapshotHookForTest installs a barrier after a merge has +// captured its reversal snapshot but before it mutates referenced rows. +func (s *Store) SetPersonMergeAfterSnapshotHookForTest(fn func()) func() { + s.personMergeAfterSnapshotHook = fn + return func() { s.personMergeAfterSnapshotHook = nil } +} diff --git a/internal/store/identity_match_apply.go b/internal/store/identity_match_apply.go index e381b4c13..bd07872f8 100644 --- a/internal/store/identity_match_apply.go +++ b/internal/store/identity_match_apply.go @@ -83,24 +83,77 @@ func (s *Store) AcceptIdentityMatchCandidateContext( } accepted := candidate + beforeTransition := candidate + transitioned := false if candidate.State != IdentityMatchStateAccepted || (decidedBy == string(ProvenanceUser) && (candidate.DecidedBy == nil || *candidate.DecidedBy != string(ProvenanceUser))) { - accepted, err = s.DecideIdentityMatchCandidateContext( + if s.identityMatchAcceptBeforeDecisionHook != nil { + s.identityMatchAcceptBeforeDecisionHook() + } + accepted, beforeTransition, err = s.decideIdentityMatchCandidateContext( ctx, candidateID, IdentityMatchStateAccepted, decidedBy, notes) if err != nil { return nil, 0, err } + transitioned = true } applied, revision, _, err := s.applyAcceptedIdentityMatchCandidateContext( ctx, accepted, decidedBy) if err != nil { + if transitioned && decidedBy == string(ProvenanceUser) && + errors.Is(err, ErrPersonBindingConflict) { + if restoreErr := s.restoreIdentityMatchDecisionAfterBindingConflictContext( + ctx, beforeTransition, accepted, + ); restoreErr != nil { + return nil, 0, errors.Join(err, restoreErr) + } + } return nil, 0, err } return applied, revision, nil } +// restoreIdentityMatchDecisionAfterBindingConflictContext makes the user +// accept path compare-and-set from the caller's perspective. If a person +// binding appeared between an API preflight and application, the merge offer +// must not consume the candidate. Recovery of a previously accepted pending +// decision still records a conflict through the normal resume path. +func (s *Store) restoreIdentityMatchDecisionAfterBindingConflictContext( + ctx context.Context, before, accepted *IdentityMatchCandidate, +) error { + return s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + if accepted.DecidedAt == nil { + return errors.New("restore identity match decision: accepted decision has no timestamp") + } + result, err := tx.ExecContext(ctx, `UPDATE identity_match_candidates SET + state = ?, decided_by = ?, decided_at = ?, notes = ?, + application_pending = ?, observation_conflict_origin = ?, + pre_conflict_state = ?, + updated_at = ? + WHERE id = ? AND state = 'conflict' AND application_pending = FALSE + AND notes = ?`, + before.State, before.DecidedBy, before.DecidedAt, before.Notes, + before.applicationPending, before.conflictState.observationOrigin, + before.conflictState.preConflictState, before.UpdatedAt, before.ID, + "accepted match spans two durable person profiles; not applied", + ) + if err != nil { + return fmt.Errorf("restore identity match decision after binding conflict: %w", err) + } + if changed, rowsErr := result.RowsAffected(); rowsErr != nil { + return fmt.Errorf("count restored identity match decision: %w", rowsErr) + } else if changed != 1 { + return errors.New("restore identity match decision: candidate changed concurrently") + } + return nil + }) +} + // ResumeAcceptedIdentityMatchCandidateContext completes the link half of one // already-accepted candidate without rewriting its original decision fields. // The boolean reports whether this call inserted a new participant link. diff --git a/internal/store/identity_match_apply_test.go b/internal/store/identity_match_apply_test.go index 203b33abd..e0470a769 100644 --- a/internal/store/identity_match_apply_test.go +++ b/internal/store/identity_match_apply_test.go @@ -332,6 +332,69 @@ func TestSQLiteSystemAcceptanceCannotOverwriteConcurrentRejection(t *testing.T) "a concurrent system acceptance must not leave a rejected identity edge") } +func TestFailedUserAcceptanceRestoresLockedDecisionState(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + + left, err := st.EnsureParticipantByIdentifier( + "beeper", "@rollback-race-left:beeper.local", "Test User") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier( + "beeper", "@rollback-race-right:beeper.local", "Test User") + require.NoError(err) + _, _, err = st.CreatePersonFromParticipantContext(ctx, left) + require.NoError(err) + _, _, err = st.CreatePersonFromParticipantContext(ctx, right) + require.NoError(err) + candidate := upsertPairCandidate( + t, st, left, right, store.IdentityMatchStableProviderID) + + acceptanceRead := make(chan struct{}) + resumeAcceptance := make(chan struct{}) + var releaseOnce sync.Once + releaseAcceptance := func() { + releaseOnce.Do(func() { close(resumeAcceptance) }) + } + restoreHook := st.SetIdentityMatchAcceptBeforeDecisionHookForTest(func() { + close(acceptanceRead) + <-resumeAcceptance + }) + t.Cleanup(restoreHook) + t.Cleanup(releaseAcceptance) + + acceptDone := make(chan error, 1) + go func() { + _, _, acceptErr := st.AcceptIdentityMatchCandidateContext( + ctx, candidate.ID, "user", nil) + acceptDone <- acceptErr + }() + select { + case <-acceptanceRead: + case <-time.After(5 * time.Second): + require.FailNow("acceptance did not finish its initial read") + } + + rejectionNote := "rejected while acceptance waited" + _, err = st.DecideIdentityMatchCandidateContext( + ctx, candidate.ID, store.IdentityMatchStateRejected, "user", &rejectionNote) + require.NoError(err) + releaseAcceptance() + select { + case err = <-acceptDone: + require.ErrorIs(err, store.ErrPersonBindingConflict) + case <-time.After(5 * time.Second): + require.FailNow("acceptance did not finish") + } + + reloaded, err := st.GetIdentityMatchCandidateContext(ctx, candidate.ID) + require.NoError(err) + assert.Equal(store.IdentityMatchStateRejected, reloaded.State) + require.NotNil(reloaded.Notes) + assert.Equal(rejectionNote, *reloaded.Notes) +} + func TestAcceptUsernameCandidateRequiresAUser(t *testing.T) { require := require.New(t) assert := assert.New(t) @@ -383,6 +446,57 @@ func TestAcceptAcrossDifferentPersonsBecomesAConflict(t *testing.T) { "the failed accept is recorded as a conflict for review") } +func TestFailedUserAcceptPreservesObservationConflictCleanup(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + left, err := st.EnsureParticipantByIdentifier("example", "rollback-left", "Left") + require.NoError(err) + right, err := st.EnsureParticipantByIdentifier("example", "rollback-right", "Right") + require.NoError(err) + normalized := "rollback-shared@example.org" + candidate, created, err := st.UpsertIdentityMatchCandidateContext( + ctx, store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchParticipant, LeftID: left, + RightKind: store.IdentityMatchParticipant, RightID: right, + Basis: store.IdentityMatchEmail, NormalizedValue: &normalized, + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceUser, + }, + ) + require.NoError(err) + require.True(created) + input := store.ParticipantContactObservationInput{ + AddressKind: store.ContactAddressEmail, OriginalValue: normalized, + ProviderUserID: new("provider-left"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceArchiveObservation}, + } + leftObservation, err := st.RecordContactObservationContext(ctx, left, input) + require.NoError(err) + input.ProviderUserID = new("provider-right") + conflicting, err := st.RecordContactObservationContext(ctx, right, input) + require.NoError(err) + require.True(conflicting.Conflicting) + assert.Equal(candidate.ID, *conflicting.CandidateID) + _, _, err = st.CreatePersonFromParticipantContext(ctx, left) + require.NoError(err) + _, _, err = st.CreatePersonFromParticipantContext(ctx, right) + require.NoError(err) + + _, _, err = st.AcceptIdentityMatchCandidateContext(ctx, candidate.ID, "user", nil) + require.ErrorIs(err, store.ErrPersonBindingConflict) + restored, err := st.GetIdentityMatchCandidateContext(ctx, candidate.ID) + require.NoError(err) + assert.Equal(store.IdentityMatchStateConflict, restored.State) + + require.NoError(st.SupersedeParticipantObservationContext( + ctx, left, leftObservation.Observation.Envelope.ID, nil, + )) + demoted, err := st.GetIdentityMatchCandidateContext(ctx, candidate.ID) + require.NoError(err) + assert.Equal(store.IdentityMatchStateCandidate, demoted.State) +} + func TestAcceptRejectsUnsupportedEndpointKinds(t *testing.T) { require := require.New(t) assert := assert.New(t) diff --git a/internal/store/identity_match_candidates.go b/internal/store/identity_match_candidates.go index 5d65b5e70..d41d8e320 100644 --- a/internal/store/identity_match_candidates.go +++ b/internal/store/identity_match_candidates.go @@ -75,6 +75,11 @@ func (s IdentityMatchState) valid() bool { } } +type identityMatchConflictState struct { + observationOrigin sql.NullString + preConflictState sql.NullString +} + type IdentityMatchCandidate struct { ID int64 `json:"id"` LeftKind IdentityMatchEndpointKind `json:"left_kind"` @@ -97,6 +102,7 @@ type IdentityMatchCandidate struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` applicationPending bool + conflictState identityMatchConflictState } type IdentityMatchEvidence struct { @@ -237,7 +243,7 @@ func validateIdentityMatchEndpointTx( case IdentityMatchObservation: table = "participant_contact_observations" case IdentityMatchContactPoint: - table = "person_contact_points" + table = personContactPointsTableName case IdentityMatchCardDAVResource: table = "carddav_resources" default: @@ -605,10 +611,23 @@ func (s *Store) DecideIdentityMatchCandidateContext( decidedBy string, notes *string, ) (*IdentityMatchCandidate, error) { + candidate, _, err := s.decideIdentityMatchCandidateContext( + ctx, candidateID, state, decidedBy, notes) + return candidate, err +} + +func (s *Store) decideIdentityMatchCandidateContext( + ctx context.Context, + candidateID int64, + state IdentityMatchState, + decidedBy string, + notes *string, +) (*IdentityMatchCandidate, *IdentityMatchCandidate, error) { if !state.valid() { - return nil, ErrInvalidIdentityMatchState + return nil, nil, ErrInvalidIdentityMatchState } var candidate *IdentityMatchCandidate + var before *IdentityMatchCandidate err := s.withTxContext(ctx, func(tx *loggedTx) error { if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { return err @@ -617,6 +636,7 @@ func (s *Store) DecideIdentityMatchCandidateContext( if err != nil { return err } + before = current if current.State == IdentityMatchStateAccepted && state == IdentityMatchStateRejected { if current.DecidedBy != nil && *current.DecidedBy == string(ProvenanceUser) { return ErrIdentityMatchAlreadyAccepted @@ -681,7 +701,7 @@ func (s *Store) DecideIdentityMatchCandidateContext( candidate, err = getIdentityMatchCandidateTx(ctx, tx, candidateID) return err }) - return candidate, err + return candidate, before, err } // rejectSystemAcceptedIdentityMatchTxContext withdraws the direct edge that @@ -951,7 +971,20 @@ func identityMatchCandidateRedirectTx( func (s *Store) rewriteIdentityMatchCandidatesForMergeTx( ctx context.Context, tx *loggedTx, oldID, newID int64, edges []linkEdge, ) error { - // The participant merge contracts oldID into newID after candidate + return s.rewriteIdentityMatchCandidatesForEndpointMergeTx( + ctx, tx, IdentityMatchParticipant, oldID, newID, edges, false, + ) +} + +func (s *Store) rewriteIdentityMatchCandidatesForEndpointMergeTx( + ctx context.Context, + tx *loggedTx, + kind IdentityMatchEndpointKind, + oldID, newID int64, + edges []linkEdge, + preferExisting bool, +) error { + // Participant endpoint merges contract oldID into newID after candidate // reconciliation. Add that virtual edge now so an accepted survivor-side // collision sees the connectivity that the same transaction will retain. contractedEdges := make([]linkEdge, 0, len(edges)+1) @@ -962,22 +995,22 @@ func (s *Store) rewriteIdentityMatchCandidatesForMergeTx( WHERE (left_kind = ? AND left_id = ?) OR (right_kind = ? AND right_id = ?) ORDER BY id`+s.dialect.SelectForUpdate(), - IdentityMatchParticipant, oldID, IdentityMatchParticipant, oldID, + kind, oldID, kind, oldID, ) if err != nil { - return fmt.Errorf("load identity match candidates for participant merge: %w", err) + return fmt.Errorf("load identity match candidates for endpoint merge: %w", err) } candidates, err := scanIdentityMatchCandidateMergeRows(rows) if err != nil { - return fmt.Errorf("scan identity match candidates for participant merge: %w", err) + return fmt.Errorf("scan identity match candidates for endpoint merge: %w", err) } for _, candidate := range candidates { appliedAccepted := acceptedMergeCandidateIsLinked(candidate, linkAdjacency) - if candidate.LeftKind == IdentityMatchParticipant && candidate.LeftID == oldID { + if candidate.LeftKind == kind && candidate.LeftID == oldID { candidate.LeftID = newID } - if candidate.RightKind == IdentityMatchParticipant && candidate.RightID == oldID { + if candidate.RightKind == kind && candidate.RightID == oldID { candidate.RightID = newID } leftKind, leftID, rightKind, rightID, canonicalErr := canonicalMatchEndpoints( @@ -1027,8 +1060,16 @@ func (s *Store) rewriteIdentityMatchCandidatesForMergeTx( continue } + preferredID := int64(0) + if preferExisting { + preferredID = collisions[0].ID + } + conflictNote := "participant merge reconciled opposing identity decisions" + if kind == IdentityMatchPerson { + conflictNote = "person merge reconciled opposing identity decisions" + } if err := s.collapseIdentityMatchCandidateMergeGroupTx( - ctx, tx, group, appliedAccepted, + ctx, tx, group, appliedAccepted, preferredID, conflictNote, ); err != nil { return err } @@ -1070,11 +1111,21 @@ func (s *Store) collapseIdentityMatchCandidateMergeGroupTx( tx *loggedTx, group []identityMatchCandidateMergeRow, appliedAccepted bool, + preferredID int64, + conflictNote string, ) error { sort.Slice(group, func(i, j int) bool { return group[i].ID < group[j].ID }) + if preferredID > 0 { + for index := range group { + if group[index].ID == preferredID { + group[0], group[index] = group[index], group[0] + break + } + } + } winner := group[0] state, decidedBy, decidedAt, notes := reconcileIdentityMatchCandidateMergeState( - group, appliedAccepted) + group, appliedAccepted, conflictNote) confidence, source, sourceRef := identityMatchCandidateMergeConfidenceProvenance(group) observationOrigin := reconcileIdentityMatchCandidateMergeObservationOrigin(group, state) preConflict := reconcileIdentityMatchCandidateMergePreConflictState(group, state) @@ -1194,7 +1245,7 @@ func reconcileIdentityMatchCandidateMergeObservationOrigin( } func reconcileIdentityMatchCandidateMergeState( - group []identityMatchCandidateMergeRow, appliedAccepted bool, + group []identityMatchCandidateMergeRow, appliedAccepted bool, conflictNote string, ) (IdentityMatchState, sql.NullString, sql.NullTime, sql.NullString) { hasAccepted, hasRejected := false, false state := IdentityMatchStateCandidate @@ -1225,7 +1276,7 @@ func reconcileIdentityMatchCandidateMergeState( sql.NullString{String: "system", Valid: true}, sql.NullTime{Time: time.Now().UTC(), Valid: true}, sql.NullString{ - String: "participant merge reconciled opposing identity decisions", + String: conflictNote, Valid: true, } } @@ -1333,7 +1384,8 @@ const identityMatchCandidateSelect = `SELECT c.id, c.left_kind, c.left_id, c.right_kind, c.right_id, c.basis, cs.slug, c.scope_kind, c.scope_value, c.normalized_value, c.state, c.confidence, c.source, c.source_ref, c.decided_by, c.decided_at, - c.notes, c.created_at, c.updated_at, c.application_pending + c.notes, c.created_at, c.updated_at, c.application_pending, + c.observation_conflict_origin, c.pre_conflict_state FROM identity_match_candidates c LEFT JOIN communication_services cs ON cs.id = c.service_id` @@ -1411,7 +1463,8 @@ func scanIdentityMatchCandidate(row scanner) (*IdentityMatchCandidate, error) { &serviceSlug, &scopeKind, &scopeValue, &normalizedValue, &candidate.State, &confidence, &candidate.Source, &sourceRef, &decidedBy, &decidedAt, ¬es, &candidate.CreatedAt, &candidate.UpdatedAt, - &candidate.applicationPending, + &candidate.applicationPending, &candidate.conflictState.observationOrigin, + &candidate.conflictState.preConflictState, ); err != nil { return nil, err } diff --git a/internal/store/messages.go b/internal/store/messages.go index 1daa63445..16325e5c8 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -3402,6 +3402,11 @@ func (s *Store) MergeParticipants(oldID, newID int64) error { if err := s.bumpParticipantIdentifierRevision(tx); err != nil { return err } + if err := rewritePersonMergeParticipantLineageTx( + context.Background(), tx, oldID, newID, + ); err != nil { + return err + } _, err = tx.Exec(`DELETE FROM participants WHERE id = ?`, oldID) return err }) diff --git a/internal/store/migrate_phone_unique.go b/internal/store/migrate_phone_unique.go index 98e886200..a201957e9 100644 --- a/internal/store/migrate_phone_unique.go +++ b/internal/store/migrate_phone_unique.go @@ -456,6 +456,9 @@ func (s *Store) mergeParticipant(ctx context.Context, tx *loggedTx, winner, lose // (8) Finally drop the loser. participant_identifiers cascades; the // other FKs are already cleared by the repoints above. + if err := rewritePersonMergeParticipantLineageTx(ctx, tx, loser, winner); err != nil { + return err + } if _, err := tx.ExecContext(ctx, `DELETE FROM participants WHERE id = ?`, loser); err != nil { return fmt.Errorf("delete loser participant id=%d: %w", loser, err) } diff --git a/internal/store/migrate_vcard_source_resource_identity_test.go b/internal/store/migrate_vcard_source_resource_identity_test.go new file mode 100644 index 000000000..f396d73c4 --- /dev/null +++ b/internal/store/migrate_vcard_source_resource_identity_test.go @@ -0,0 +1,35 @@ +package store + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitSchemaUpgradesBeforeSourceResourceIdentityIndexes(t *testing.T) { + require := require.New(t) + st, err := OpenForTest(filepath.Join(t.TempDir(), "pre-source-resource.db")) + require.NoError(err) + t.Cleanup(func() { _ = st.Close() }) + require.NoError(st.InitSchema()) + _, err = st.db.Exec(`DROP INDEX idx_person_names_property_identity`) + require.NoError(err) + _, err = st.db.Exec(`ALTER TABLE person_names DROP COLUMN source_resource_uid`) + require.NoError(err) + _, err = st.db.Exec(`DELETE FROM applied_migrations WHERE name = ?`, + migrationVCardSourceResourceIdentity) + require.NoError(err) + + require.NoError(st.InitSchema(), + "bootstrap indexes must not reference columns added by later migrations") + var columnCount int + require.NoError(st.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('person_names') + WHERE name = 'source_resource_uid'`).Scan(&columnCount)) + assert.Equal(t, 1, columnCount) + var indexSQL string + require.NoError(st.db.QueryRow(`SELECT sql FROM sqlite_master + WHERE type = 'index' AND name = 'idx_person_names_property_identity'`).Scan(&indexSQL)) + assert.Contains(t, indexSQL, "COALESCE(source_resource_uid, '')") +} diff --git a/internal/store/organization_attributes.go b/internal/store/organization_attributes.go index 3b11a2eb3..769a6889f 100644 --- a/internal/store/organization_attributes.go +++ b/internal/store/organization_attributes.go @@ -389,6 +389,11 @@ func (s *Store) supersedeOrganizationAttributeValueOnce( if err := retractableAttributeDefinition(*definition); err != nil { return err } + if definition.ValueType == AttributeValueRecordReference { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + } ordinal := int64(0) if input.Ordinal != nil { if definition.Cardinality == AttributeCardinalitySingle && *input.Ordinal != 0 { diff --git a/internal/store/participant_links.go b/internal/store/participant_links.go index c7bf7eece..43bb1c0dc 100644 --- a/internal/store/participant_links.go +++ b/internal/store/participant_links.go @@ -416,6 +416,11 @@ func (s *Store) linkParticipantsContextGuardedOwned( return fmt.Errorf("insert participant link: %w", insertErr) } if personID != 0 { + if err := extendActivePersonMergeLineageTx( + ctx, tx, personID, lo, hi, edges, + ); err != nil { + return err + } changed, err := s.bindPersonParticipantsTx( ctx, tx, personID, unionMembers) if err != nil { @@ -436,6 +441,149 @@ func (s *Store) linkParticipantsContextGuardedOwned( return revision, linked, err } +type activePersonMergeLineageState struct { + origin string + splitID sql.NullInt64 +} + +// extendActivePersonMergeLineageTx keeps reversible merge lineage aligned +// with identity links added after the merge. Each pre-link component inherits +// its one unambiguous state. A lineage-free component inherits the state of +// the participant it is directly linked to, which remains deterministic even +// when that participant's existing component contains both merge origins. +func extendActivePersonMergeLineageTx( + ctx context.Context, + tx *loggedTx, + personID int64, + lo, hi int64, + edges []linkEdge, +) error { + left := sortedComponentMembers(lo, edges) + right := sortedComponentMembers(hi, edges) + members := make(map[int64]struct{}, len(left)+len(right)) + for _, participantID := range append(slices.Clone(left), right...) { + members[participantID] = struct{}{} + } + rows, err := tx.QueryContext(ctx, `SELECT lineage.merge_id, + lineage.participant_id, lineage.origin_side, lineage.split_id + FROM person_merge_participants lineage + JOIN person_merges merge_record ON merge_record.id = lineage.merge_id + WHERE merge_record.current_person_id = ? + ORDER BY lineage.merge_id, lineage.participant_id`, personID) + if err != nil { + return fmt.Errorf("load active person merge lineage for link: %w", err) + } + defer func() { _ = rows.Close() }() + lineage := make(map[int64]map[int64]activePersonMergeLineageState) + for rows.Next() { + var mergeID, participantID int64 + var state activePersonMergeLineageState + if err := rows.Scan(&mergeID, &participantID, &state.origin, &state.splitID); err != nil { + return fmt.Errorf("scan active person merge lineage for link: %w", err) + } + if _, included := members[participantID]; !included { + continue + } + if lineage[mergeID] == nil { + lineage[mergeID] = make(map[int64]activePersonMergeLineageState) + } + lineage[mergeID][participantID] = state + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate active person merge lineage for link: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close active person merge lineage for link: %w", err) + } + mergeIDs := make([]int64, 0, len(lineage)) + for mergeID := range lineage { + mergeIDs = append(mergeIDs, mergeID) + } + slices.Sort(mergeIDs) + for _, mergeID := range mergeIDs { + mergeLineage := lineage[mergeID] + leftState, leftPresent, leftUnambiguous := activePersonMergeComponentState( + left, mergeLineage, + ) + rightState, rightPresent, rightUnambiguous := activePersonMergeComponentState( + right, mergeLineage, + ) + assignments := make(map[int64]activePersonMergeLineageState) + if leftUnambiguous { + assignPersonMergeComponentLineage(assignments, left, leftState) + } + if rightUnambiguous { + assignPersonMergeComponentLineage(assignments, right, rightState) + } + if !leftPresent { + state, found := mergeLineage[hi] + if !found && rightUnambiguous { + state, found = rightState, true + } + if found { + assignPersonMergeComponentLineage(assignments, left, state) + } + } + if !rightPresent { + state, found := mergeLineage[lo] + if !found && leftUnambiguous { + state, found = leftState, true + } + if found { + assignPersonMergeComponentLineage(assignments, right, state) + } + } + participantIDs := make([]int64, 0, len(assignments)) + for participantID := range assignments { + participantIDs = append(participantIDs, participantID) + } + slices.Sort(participantIDs) + for _, participantID := range participantIDs { + state := assignments[participantID] + if _, err := tx.ExecContext(ctx, `INSERT INTO person_merge_participants + (merge_id, participant_id, origin_side, split_id) + VALUES (?, ?, ?, ?) + ON CONFLICT (merge_id, participant_id) DO NOTHING`, + mergeID, participantID, state.origin, state.splitID); err != nil { + return fmt.Errorf("extend person merge lineage for linked participant: %w", err) + } + } + } + return nil +} + +func activePersonMergeComponentState( + component []int64, + lineage map[int64]activePersonMergeLineageState, +) (activePersonMergeLineageState, bool, bool) { + var state activePersonMergeLineageState + present := false + for _, participantID := range component { + current, found := lineage[participantID] + if !found { + continue + } + if !present { + state, present = current, true + continue + } + if state != current { + return activePersonMergeLineageState{}, true, false + } + } + return state, present, present +} + +func assignPersonMergeComponentLineage( + assignments map[int64]activePersonMergeLineageState, + component []int64, + state activePersonMergeLineageState, +) { + for _, participantID := range component { + assignments[participantID] = state + } +} + // UnlinkParticipants removes the edge between a and b, if present. Returns // ErrParticipantNotFound (wrapped) if either ID is not a participants row. // Idempotent: unlinking a pair with no edge is a no-op that returns the @@ -568,9 +716,59 @@ func (s *Store) rejectAcceptedIdentityMatchesAcrossUnlinkTx( if len(candidateIDs) == 0 { return nil } + return s.rejectAcceptedIdentityMatchCandidatesTx( + context.Background(), tx, candidateIDs, "crossing unlink") +} + +func (s *Store) rejectAcceptedIdentityMatchesAcrossPersonSplitTx( + ctx context.Context, tx *loggedTx, selected []int64, +) error { + selectedSet := make(map[int64]struct{}, len(selected)) + for _, participantID := range selected { + selectedSet[participantID] = struct{}{} + } + rows, err := tx.QueryContext(ctx, `SELECT id, left_id, right_id + FROM identity_match_candidates + WHERE state = ? AND left_kind = ? AND right_kind = ?`, + IdentityMatchStateAccepted, + IdentityMatchParticipant, + IdentityMatchParticipant, + ) + if err != nil { + return fmt.Errorf("find accepted identity matches crossing person split: %w", err) + } + candidateIDs := make([]int64, 0) + for rows.Next() { + var candidateID, leftID, rightID int64 + if err := rows.Scan(&candidateID, &leftID, &rightID); err != nil { + _ = rows.Close() + return fmt.Errorf("scan accepted identity match crossing person split: %w", err) + } + _, leftSelected := selectedSet[leftID] + _, rightSelected := selectedSet[rightID] + if leftSelected != rightSelected { + candidateIDs = append(candidateIDs, candidateID) + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate accepted identity matches crossing person split: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close accepted identity matches crossing person split: %w", err) + } + return s.rejectAcceptedIdentityMatchCandidatesTx( + ctx, tx, candidateIDs, "crossing person split") +} +func (s *Store) rejectAcceptedIdentityMatchCandidatesTx( + ctx context.Context, + tx *loggedTx, + candidateIDs []int64, + operation string, +) error { for _, candidateID := range candidateIDs { - if _, err := tx.Exec(` + if _, err := tx.ExecContext(ctx, ` UPDATE identity_match_candidates SET state = ?, decided_by = ?, decided_at = `+s.dialect.Now()+`, pre_conflict_state = NULL, application_pending = FALSE, @@ -579,7 +777,7 @@ func (s *Store) rejectAcceptedIdentityMatchesAcrossUnlinkTx( IdentityMatchStateRejected, "user", IdentityMatchStateAccepted, candidateID, ); err != nil { - return fmt.Errorf("reject identity match %d crossing unlink: %w", candidateID, err) + return fmt.Errorf("reject identity match %d %s: %w", candidateID, operation, err) } } return nil diff --git a/internal/store/person_attributes.go b/internal/store/person_attributes.go index 0f30f88f1..567b2d81a 100644 --- a/internal/store/person_attributes.go +++ b/internal/store/person_attributes.go @@ -280,7 +280,7 @@ func (s *Store) verifyAttributeRecordTargetTx( return nil } switch *value.RecordType { - case "person": + case string(AttributeObjectPerson): var exists int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM persons WHERE id = ?`, *value.RecordID, @@ -426,6 +426,11 @@ func (s *Store) supersedePersonAttributeValueOnce( if err := retractableAttributeDefinition(*definition); err != nil { return err } + if definition.ValueType == AttributeValueRecordReference { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + } ordinal := int64(0) if input.Ordinal != nil { if definition.Cardinality == AttributeCardinalitySingle && *input.Ordinal != 0 { diff --git a/internal/store/person_merge_inspection.go b/internal/store/person_merge_inspection.go new file mode 100644 index 000000000..00fe76550 --- /dev/null +++ b/internal/store/person_merge_inspection.go @@ -0,0 +1,513 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +func (s *Store) ListPersonMergesContext( + ctx context.Context, personID int64, +) ([]PersonMergeSummary, error) { + return s.ListPersonMergesPageContext(ctx, personID, 100, 0) +} + +func (s *Store) ListPersonMergesPageContext( + ctx context.Context, personID int64, limit, offset int, +) ([]PersonMergeSummary, error) { + if personID <= 0 { + return nil, ErrPersonNotFound + } + if limit <= 0 { + limit = 100 + } + limit = min(limit, 500) + if offset < 0 { + offset = 0 + } + result := []PersonMergeSummary{} + err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + if _, err := s.getPersonTx(ctx, tx, personID); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, `SELECT + merge_record.id, merge_record.survivor_person_id_at_merge, + merge_record.absorbed_person_id, merge_record.current_person_id, + merge_record.survivor_uid, merge_record.absorbed_uid, + merge_record.survivor_revision_before, merge_record.absorbed_revision_before, + merge_record.survivor_revision_after, merge_record.actor, + merge_record.snapshot_version, merge_record.snapshot_sha256, merge_record.created_at, + (SELECT COUNT(*) FROM person_merge_participants lineage_count + WHERE lineage_count.merge_id = merge_record.id), + (SELECT COUNT(*) FROM person_merge_rows row_count + WHERE row_count.merge_id = merge_record.id), + (SELECT COUNT(*) FROM person_splits split_count + WHERE split_count.merge_id = merge_record.id), + (SELECT COUNT(*) FROM person_merge_review_candidates candidate_count + WHERE candidate_count.merge_id = merge_record.id AND candidate_count.state = 'pending') + FROM person_merges merge_record + WHERE merge_record.current_person_id = ? + OR EXISTS ( + SELECT 1 FROM person_splits split_record + WHERE split_record.merge_id = merge_record.id + AND (split_record.source_person_id = ? OR split_record.new_person_id = ?) + ) + OR EXISTS ( + SELECT 1 FROM person_merge_participants lineage + JOIN person_participants binding + ON binding.participant_id = lineage.participant_id + WHERE lineage.merge_id = merge_record.id AND binding.person_id = ? + ) + ORDER BY merge_record.id DESC LIMIT ? OFFSET ?`, + personID, personID, personID, personID, limit, offset) + if err != nil { + return fmt.Errorf("list person merge summaries: %w", err) + } + mergeIDs := []int64{} + for rows.Next() { + var summary PersonMergeSummary + var currentID sql.NullInt64 + if err := rows.Scan( + &summary.Merge.ID, &summary.Merge.SurvivorPersonID, + &summary.Merge.AbsorbedPersonID, ¤tID, + &summary.Merge.SurvivorVCardUID, &summary.Merge.AbsorbedVCardUID, + &summary.Merge.SurvivorRevisionBefore, &summary.Merge.AbsorbedRevisionBefore, + &summary.Merge.SurvivorRevisionAfter, &summary.Merge.Actor, + &summary.Merge.SnapshotVersion, &summary.Merge.SnapshotSHA256, + &summary.Merge.CreatedAt, &summary.ParticipantCount, &summary.RowCount, + &summary.SplitCount, &summary.PendingCandidateCount, + ); err != nil { + _ = rows.Close() + return fmt.Errorf("scan person merge summary: %w", err) + } + if currentID.Valid { + summary.Merge.CurrentPersonID = ¤tID.Int64 + } + summary.RowActionCounts = map[string]int{} + mergeIDs = append(mergeIDs, summary.Merge.ID) + result = append(result, summary) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate person merge summaries: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close person merge summaries: %w", err) + } + if len(mergeIDs) == 0 { + return nil + } + actionRows, err := tx.QueryContext(ctx, `SELECT merge_id, action, COUNT(*) + FROM person_merge_rows WHERE merge_id IN (`+ + personMergeSnapshotPlaceholders(len(mergeIDs))+`) + GROUP BY merge_id, action ORDER BY merge_id, action`, + personMergeSnapshotIDArgs(mergeIDs)...) + if err != nil { + return fmt.Errorf("count person merge page row actions: %w", err) + } + summariesByID := make(map[int64]*PersonMergeSummary, len(result)) + for index := range result { + summariesByID[result[index].Merge.ID] = &result[index] + } + for actionRows.Next() { + var mergeID int64 + var action string + var count int + if err := actionRows.Scan(&mergeID, &action, &count); err != nil { + _ = actionRows.Close() + return fmt.Errorf("scan person merge page row action: %w", err) + } + summariesByID[mergeID].RowActionCounts[action] = count + } + if err := actionRows.Err(); err != nil { + _ = actionRows.Close() + return fmt.Errorf("iterate person merge page row actions: %w", err) + } + if err := actionRows.Close(); err != nil { + return fmt.Errorf("close person merge page row actions: %w", err) + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *Store) GetPersonMergeContext( + ctx context.Context, mergeID int64, +) (*PersonMergeDetail, error) { + if mergeID <= 0 { + return nil, ErrPersonMergeNotFound + } + var detail *PersonMergeDetail + err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + merge, err := s.getPersonMergeTx(ctx, tx, mergeID) + if err != nil { + return err + } + participants, err := listPersonMergeParticipantsTx(ctx, tx, mergeID) + if err != nil { + return err + } + rows, err := listPersonMergeRowsTx(ctx, tx, mergeID) + if err != nil { + return err + } + splits, err := listPersonSplitsTx(ctx, tx, mergeID) + if err != nil { + return err + } + candidates, err := listPersonMergeReviewCandidatesTx(ctx, tx, mergeID) + if err != nil { + return err + } + detail = &PersonMergeDetail{ + Merge: *merge, Participants: participants, Rows: rows, + Splits: splits, ReviewCandidates: candidates, + } + return nil + }) + if err != nil { + return nil, err + } + return detail, nil +} + +func listPersonMergeParticipantsTx( + ctx context.Context, tx *loggedTx, mergeID int64, +) ([]PersonMergeParticipant, error) { + rows, err := tx.QueryContext(ctx, `SELECT merge_id, participant_id, origin_side, split_id + FROM person_merge_participants WHERE merge_id = ? ORDER BY participant_id`, mergeID) + if err != nil { + return nil, fmt.Errorf("list person merge participants: %w", err) + } + defer func() { _ = rows.Close() }() + result := []PersonMergeParticipant{} + for rows.Next() { + var item PersonMergeParticipant + var splitID sql.NullInt64 + if err := rows.Scan(&item.MergeID, &item.ParticipantID, &item.OriginSide, &splitID); err != nil { + return nil, fmt.Errorf("scan person merge participant: %w", err) + } + if splitID.Valid { + item.SplitID = &splitID.Int64 + } + result = append(result, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person merge participants: %w", err) + } + return result, nil +} + +func listPersonMergeRowsTx( + ctx context.Context, tx *loggedTx, mergeID int64, +) ([]PersonMergeRow, error) { + rows, err := tx.QueryContext(ctx, `SELECT merge_id, table_name, original_row_id, + original_row_key, current_row_id, current_row_key, origin_side, + provenance_kind, participant_id, action, snapshot_path, split_id + FROM person_merge_rows WHERE merge_id = ? ORDER BY table_name, original_row_key`, mergeID) + if err != nil { + return nil, fmt.Errorf("list person merge rows: %w", err) + } + defer func() { _ = rows.Close() }() + result := []PersonMergeRow{} + for rows.Next() { + var item PersonMergeRow + var originalID, currentID, participantID, splitID sql.NullInt64 + var currentKey sql.NullString + if err := rows.Scan( + &item.MergeID, &item.TableName, &originalID, &item.OriginalRowKey, + ¤tID, ¤tKey, &item.OriginSide, &item.ProvenanceKind, + &participantID, &item.Action, &item.SnapshotPath, &splitID, + ); err != nil { + return nil, fmt.Errorf("scan person merge row: %w", err) + } + if originalID.Valid { + item.OriginalRowID = &originalID.Int64 + } + if currentID.Valid { + item.CurrentRowID = ¤tID.Int64 + } + if currentKey.Valid { + item.CurrentRowKey = ¤tKey.String + } + if participantID.Valid { + item.ParticipantID = &participantID.Int64 + } + if splitID.Valid { + item.SplitID = &splitID.Int64 + } + result = append(result, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person merge rows: %w", err) + } + return result, nil +} + +func listPersonSplitsTx( + ctx context.Context, tx *loggedTx, mergeID int64, +) ([]PersonSplit, error) { + rows, err := tx.QueryContext(ctx, `SELECT id, merge_id, source_person_id, + new_person_id, new_person_uid, source_revision_before, + source_revision_after, actor, is_exact_reversal, created_at + FROM person_splits WHERE merge_id = ? ORDER BY id`, mergeID) + if err != nil { + return nil, fmt.Errorf("list person splits: %w", err) + } + defer func() { _ = rows.Close() }() + result := []PersonSplit{} + for rows.Next() { + var split PersonSplit + if err := rows.Scan( + &split.ID, &split.MergeID, &split.SourcePersonID, &split.NewPersonID, + &split.NewPersonUID, &split.SourceRevisionBefore, &split.SourceRevisionAfter, + &split.Actor, &split.ExactReversal, &split.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scan person split: %w", err) + } + result = append(result, split) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person splits: %w", err) + } + return result, nil +} + +func (s *Store) GetPersonMergeSnapshotContext( + ctx context.Context, mergeID int64, +) (*PersonMergeSnapshotResponse, error) { + if mergeID <= 0 { + return nil, ErrPersonMergeNotFound + } + var blob []byte + var version int + var hash string + err := s.db.QueryRowContext(ctx, `SELECT snapshot_version, snapshot_sha256, + snapshot_blob FROM person_merges WHERE id = ?`, mergeID).Scan(&version, &hash, &blob) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrPersonMergeNotFound + } + if err != nil { + return nil, fmt.Errorf("load person merge snapshot: %w", err) + } + snapshot, err := decodePersonMergeSnapshot(blob, hash) + if err != nil { + return nil, err + } + canonical, err := json.Marshal(snapshot) + if err != nil { + return nil, fmt.Errorf("encode person merge snapshot response: %w", err) + } + return &PersonMergeSnapshotResponse{ + Version: version, SHA256: hash, JSON: json.RawMessage(canonical), + }, nil +} + +func (s *Store) DecidePersonMergeCandidateContext( + ctx context.Context, request PersonMergeCandidateDecisionRequest, +) (*PersonMergeCandidateDecisionResult, error) { + request.Actor = strings.TrimSpace(request.Actor) + if request.CandidateID <= 0 || request.PersonID <= 0 || + request.ExpectedPersonRevision <= 0 || request.Actor == "" || + (request.Decision != PersonMergeCandidateAccept && + request.Decision != PersonMergeCandidateReject) { + return nil, ErrPersonMergeInvalid + } + return retryContendedWrite(ctx, s, "decide person merge candidate", + func() (*PersonMergeCandidateDecisionResult, error) { + return s.decidePersonMergeCandidateOnce(ctx, request) + }) +} + +func (s *Store) decidePersonMergeCandidateOnce( + ctx context.Context, request PersonMergeCandidateDecisionRequest, +) (*PersonMergeCandidateDecisionResult, error) { + var result *PersonMergeCandidateDecisionResult + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + var revision int64 + err := tx.QueryRowContext(ctx, `SELECT revision FROM persons WHERE id = ?`+ + s.dialect.SelectForUpdate(), request.PersonID).Scan(&revision) + if errors.Is(err, sql.ErrNoRows) { + return ErrPersonNotFound + } + if err != nil { + return fmt.Errorf("lock merge candidate person: %w", err) + } + candidate, err := getPersonMergeReviewCandidateTx( + ctx, tx, request.CandidateID, s.dialect.SelectForUpdate()) + if err != nil { + return err + } + wantedState := "accepted" + if request.Decision == PersonMergeCandidateReject { + wantedState = "rejected" + } + if candidate.PersonID != request.PersonID { + return ErrPersonMergeCandidateState + } + if candidate.State == wantedState { + result = &PersonMergeCandidateDecisionResult{ + PersonMergeReviewCandidate: *candidate, PersonRevision: revision, + } + return nil + } + if candidate.State != "pending" { + return ErrPersonMergeCandidateState + } + if revision != request.ExpectedPersonRevision { + return ErrPersonRevisionConflict + } + var resolutionID any + if request.Decision == PersonMergeCandidateAccept { + insertedID, err := s.acceptPersonMergeCandidateValueTx(ctx, tx, candidate, request.Actor) + if err != nil { + return err + } + resolutionID = insertedID + } + decisionResult, err := tx.ExecContext(ctx, `UPDATE person_merge_review_candidates SET + state = ?, resolution_value_id = ?, reviewed_by = ?, reviewed_at = ? + WHERE id = ? AND state = 'pending'`, wantedState, resolutionID, + request.Actor, time.Now().UTC(), candidate.ID) + if err != nil { + return fmt.Errorf("decide person merge candidate: %w", err) + } + if changed, err := decisionResult.RowsAffected(); err != nil { + return fmt.Errorf("count person merge candidate decision: %w", err) + } else if changed != 1 { + return ErrPersonMergeCandidateState + } + if err := s.bumpPersonRevisionsTx(ctx, tx, request.PersonID); err != nil { + return err + } + candidate, err = getPersonMergeReviewCandidateTx(ctx, tx, candidate.ID, "") + if err != nil { + return err + } + result = &PersonMergeCandidateDecisionResult{ + PersonMergeReviewCandidate: *candidate, PersonRevision: revision + 1, + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func getPersonMergeReviewCandidateTx( + ctx context.Context, tx *loggedTx, candidateID int64, lock string, +) (*PersonMergeReviewCandidate, error) { + rows, err := listPersonMergeReviewCandidatesQueryTx(ctx, tx, `WHERE candidate.id = ?`+lock, + candidateID) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, ErrPersonMergeCandidateNotFound + } + return &rows[0], nil +} + +func listPersonMergeReviewCandidatesQueryTx( + ctx context.Context, tx *loggedTx, suffix string, args ...any, +) ([]PersonMergeReviewCandidate, error) { + rows, err := tx.QueryContext(ctx, `SELECT candidate.id, candidate.merge_id, + candidate.survivor_person_id, candidate.definition_id, + candidate.survivor_value_id, candidate.absorbed_value_id, candidate.state, + candidate.resolution_value_id, candidate.reviewed_by, candidate.reviewed_at, + candidate.created_at FROM person_merge_review_candidates candidate `+suffix, args...) + if err != nil { + return nil, fmt.Errorf("load person merge review candidate: %w", err) + } + defer func() { _ = rows.Close() }() + result := []PersonMergeReviewCandidate{} + for rows.Next() { + var candidate PersonMergeReviewCandidate + var resolution sql.NullInt64 + var reviewedBy sql.NullString + var reviewedAt sql.NullTime + if err := rows.Scan( + &candidate.ID, &candidate.MergeID, &candidate.PersonID, + &candidate.DefinitionID, &candidate.SurvivorValueID, + &candidate.AbsorbedValueID, &candidate.State, &resolution, + &reviewedBy, &reviewedAt, &candidate.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scan person merge review candidate: %w", err) + } + if resolution.Valid { + candidate.ResolutionValueID = &resolution.Int64 + } + if reviewedBy.Valid { + candidate.ReviewedBy = &reviewedBy.String + } + if reviewedAt.Valid { + candidate.ReviewedAt = &reviewedAt.Time + } + result = append(result, candidate) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person merge review candidate: %w", err) + } + return result, nil +} + +func (s *Store) acceptPersonMergeCandidateValueTx( + ctx context.Context, tx *loggedTx, + candidate *PersonMergeReviewCandidate, actor string, +) (int64, error) { + absorbed, err := scanPersonAttributeValue(tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s + FROM person_attribute_values v + JOIN attribute_definitions d ON d.id = v.definition_id + WHERE v.id = ?`, personAttributeValueColumns), candidate.AbsorbedValueID)) + if err != nil { + return 0, fmt.Errorf("load absorbed merge candidate value: %w", err) + } + if absorbed.PersonID != candidate.PersonID || absorbed.DefinitionID != candidate.DefinitionID { + return 0, ErrPersonMergeCandidateState + } + definition, err := s.getAttributeDefinitionBySlugTx( + ctx, tx, AttributeObjectPerson, absorbed.DefinitionSlug) + if err != nil { + return 0, err + } + if err := writableAttributeDefinition(*definition); err != nil { + return 0, fmt.Errorf("%w: %w", ErrPersonMergeCandidateState, err) + } + current, found, err := s.currentPersonAttributeValueTx( + ctx, tx, candidate.PersonID, candidate.DefinitionID, absorbed.Ordinal) + if err != nil { + return 0, err + } + if !found || current.ID != candidate.SurvivorValueID { + return 0, ErrPersonMergeCandidateState + } + now := time.Now().UTC() + if _, err := s.closePersonAttributeValueTx(ctx, tx, current.ID, now, now); err != nil { + return 0, err + } + if err := s.verifyAttributeRecordTargetTx(ctx, tx, absorbed.Value); err != nil { + return 0, err + } + ordinal := absorbed.Ordinal + inserted, err := s.insertPersonAttributeValueTx(ctx, tx, *definition, + PersonAttributeValueInput{ + PersonID: candidate.PersonID, DefinitionSlug: absorbed.DefinitionSlug, + Ordinal: &ordinal, Value: absorbed.Value, Source: absorbed.Source, + SourceRef: absorbed.SourceRef, Confidence: absorbed.Confidence, Actor: &actor, + }, absorbed.Ordinal, now) + if err != nil { + return 0, err + } + return inserted.ID, nil +} diff --git a/internal/store/person_merge_snapshot.go b/internal/store/person_merge_snapshot.go new file mode 100644 index 000000000..c16548222 --- /dev/null +++ b/internal/store/person_merge_snapshot.go @@ -0,0 +1,980 @@ +package store + +import ( + "bytes" + "compress/zlib" + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "sort" + "strconv" + "strings" + "time" +) + +const personMergeSnapshotVersion = 1 + +const ( + identityMatchCandidatesTableName = "identity_match_candidates" + identityMatchCandidateSourcesTableName = "identity_match_candidate_sources" + identityMatchEvidenceTableName = "identity_match_evidence" + identityMatchEvidenceSourcesTableName = "identity_match_evidence_sources" + personMergeActionDeduplicated = "deduplicated" + personAttributeValuesTableName = "person_attribute_values" + personContactPointsTableName = "person_contact_points" + personMergeReviewCandidatesTableName = "person_merge_review_candidates" + personRelationshipsTableName = "person_relationships" + personRelationshipReviewsTableName = "person_relationship_reviews" + sourceIDColumnName = "source_id" +) + +var ErrPersonMergeSnapshotCorrupt = errors.New("person merge snapshot is corrupt") + +type personMergeOriginSide string + +const ( + personMergeOriginSurvivor personMergeOriginSide = "survivor" + personMergeOriginAbsorbed personMergeOriginSide = "absorbed" +) + +type personMergeProvenanceKind string + +const ( + personMergeProvenanceParticipantExact personMergeProvenanceKind = "participant_exact" + personMergeProvenanceAbsorbedProfile personMergeProvenanceKind = "absorbed_profile" + personMergeProvenanceDerived personMergeProvenanceKind = "derived" + personMergeProvenanceInboundReference personMergeProvenanceKind = "inbound_reference" +) + +type personMergeSnapshotValueKind string + +const ( + personMergeSnapshotNull personMergeSnapshotValueKind = "null" + personMergeSnapshotInteger personMergeSnapshotValueKind = "integer" + personMergeSnapshotReal personMergeSnapshotValueKind = "real" + personMergeSnapshotBoolean personMergeSnapshotValueKind = "boolean" + personMergeSnapshotText personMergeSnapshotValueKind = "text" + personMergeSnapshotBytes personMergeSnapshotValueKind = "bytes" +) + +// personMergeSnapshotValue is a portable, typed SQL value. A compile-time +// table registry chooses each column's kind, keeping SQLite and PostgreSQL +// driver representations from changing canonical JSON. +type personMergeSnapshotValue struct { + Kind personMergeSnapshotValueKind `json:"kind"` + Integer *int64 `json:"integer,omitempty"` + Real *float64 `json:"real,omitempty"` + Boolean *bool `json:"boolean,omitempty"` + Text *string `json:"text,omitempty"` + Bytes []byte `json:"bytes,omitempty"` +} + +type personMergeSnapshotColumn struct { + Name string `json:"name"` + Value personMergeSnapshotValue `json:"value"` +} + +type personMergeSnapshotPerson struct { + ID int64 `json:"id"` + VCardUID string `json:"vcard_uid"` + DisplayName *string `json:"display_name,omitempty"` + Revision int64 `json:"revision"` + VCardProjectionRevision int64 `json:"vcard_projection_revision"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ParticipantIDs []int64 `json:"participant_ids"` +} + +type personMergeSnapshotRow struct { + TableName string `json:"table_name"` + RowID int64 `json:"row_id"` + RowKey string `json:"row_key,omitempty"` + OriginSide personMergeOriginSide `json:"origin_side"` + ProvenanceKind personMergeProvenanceKind `json:"provenance_kind"` + ParticipantID *int64 `json:"participant_id,omitempty"` + Columns []personMergeSnapshotColumn `json:"columns"` +} + +type personMergeSnapshot struct { + Version int `json:"version"` + Persons []personMergeSnapshotPerson `json:"persons"` + Rows []personMergeSnapshotRow `json:"rows"` +} + +type personMergeReferenceKind string + +const ( + personMergeReferenceDirect personMergeReferenceKind = "direct" + personMergeReferencePolymorphic personMergeReferenceKind = "polymorphic" +) + +type personMergeReference struct { + Kind personMergeReferenceKind + IDColumn string + KindColumn string + KindValue string +} + +type personMergeTableSpec struct { + TableName string + KeyColumn string + KeyColumns []string + PersonReferences []personMergeReference + Snapshot bool +} + +func (s personMergeTableSpec) keyColumns() []string { + if len(s.KeyColumns) > 0 { + return s.KeyColumns + } + if s.KeyColumn == "" { + return nil + } + return []string{s.KeyColumn} +} + +func directPersonReference(column string) personMergeReference { + return personMergeReference{Kind: personMergeReferenceDirect, IDColumn: column} +} + +func polymorphicPersonReference(idColumn, kindColumn string) personMergeReference { + return personMergeReference{ + Kind: personMergeReferencePolymorphic, IDColumn: idColumn, + KindColumn: kindColumn, KindValue: string(AttributeObjectPerson), + } +} + +// personMergeTableRegistry is the closed allowlist for every table that can +// point at a person. Operation tables are classified but not recursively +// snapshotted; their targeted lineage updates are journaled separately. +var personMergeTableRegistry = map[string]personMergeTableSpec{ + "person_participants": { + TableName: "person_participants", KeyColumn: "participant_id", Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_tracking": { + TableName: "person_tracking", KeyColumn: "person_id", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "vcard_resource_envelopes": { + TableName: "vcard_resource_envelopes", KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "carddav_resources": { + TableName: "carddav_resources", KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "carddav_publications": { + TableName: "carddav_publications", KeyColumn: "person_id", Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_uid_aliases": { + TableName: "person_uid_aliases", KeyColumn: "retired_uid", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("surviving_person_id")}, + }, + personRelationshipsTableName: { + TableName: personRelationshipsTableName, KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{ + directPersonReference("source_person_id"), + directPersonReference("target_person_id"), + }, + }, + personRelationshipReviewsTableName: { + TableName: personRelationshipReviewsTableName, KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{ + directPersonReference("person_id"), + directPersonReference("matched_person_id"), + }, + }, + personAttributeValuesTableName: { + TableName: personAttributeValuesTableName, KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{ + directPersonReference("person_id"), + polymorphicPersonReference("value_record_id", "value_record_type"), + }, + }, + "organization_attribute_values": { + TableName: "organization_attribute_values", KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{ + polymorphicPersonReference("value_record_id", "value_record_type"), + }, + }, + "person_merges": { + TableName: "person_merges", KeyColumn: "id", Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("current_person_id")}, + }, + personMergeReviewCandidatesTableName: { + TableName: personMergeReviewCandidatesTableName, KeyColumn: "id", Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("survivor_person_id")}, + }, + "person_names": { + TableName: "person_names", KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + personContactPointsTableName: { + TableName: personContactPointsTableName, KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_addresses": { + TableName: "person_addresses", KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_dates": { + TableName: "person_dates", KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_categories": { + TableName: "person_categories", KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_media": { + TableName: "person_media", KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + identityMatchCandidatesTableName: { + TableName: identityMatchCandidatesTableName, KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{ + polymorphicPersonReference("left_id", "left_kind"), + polymorphicPersonReference("right_id", "right_kind"), + }, + }, + "identity_match_candidate_redirects": { + TableName: "identity_match_candidate_redirects", KeyColumn: "retired_candidate_id", + }, + identityMatchCandidateSourcesTableName: { + TableName: identityMatchCandidateSourcesTableName, KeyColumn: "candidate_id", + KeyColumns: []string{"candidate_id", sourceIDColumnName}, + }, + identityMatchEvidenceTableName: { + TableName: identityMatchEvidenceTableName, KeyColumn: "id", + }, + identityMatchEvidenceSourcesTableName: { + TableName: identityMatchEvidenceSourcesTableName, KeyColumn: "evidence_id", + KeyColumns: []string{"evidence_id", sourceIDColumnName}, + }, + "employments": { + TableName: "employments", KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "activity_event_persons": { + TableName: "activity_event_persons", KeyColumn: "message_id", + KeyColumns: []string{"message_id", "person_id"}, Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_contact_state": { + TableName: "person_contact_state", KeyColumn: "person_id", Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "daily_note_entry_persons": { + TableName: "daily_note_entry_persons", KeyColumn: "entry_id", + KeyColumns: []string{"entry_id", "person_id"}, Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, +} + +func (s *Store) capturePersonMergeSnapshotTx( + ctx context.Context, tx *loggedTx, survivorID, absorbedID int64, +) (personMergeSnapshot, error) { + snapshot := personMergeSnapshot{ + Version: personMergeSnapshotVersion, + Persons: make([]personMergeSnapshotPerson, 0, 2), + Rows: []personMergeSnapshotRow{}, + } + for _, personID := range []int64{survivorID, absorbedID} { + person, err := capturePersonMergeRootTx(ctx, tx, personID) + if err != nil { + return personMergeSnapshot{}, err + } + snapshot.Persons = append(snapshot.Persons, person) + } + + tables := make([]string, 0, len(personMergeTableRegistry)) + for table, spec := range personMergeTableRegistry { + if spec.Snapshot { + tables = append(tables, table) + } + } + sort.Strings(tables) + for _, table := range tables { + rows, err := s.capturePersonMergeTableTx( + ctx, tx, personMergeTableRegistry[table], survivorID, absorbedID, + ) + if err != nil { + return personMergeSnapshot{}, err + } + snapshot.Rows = append(snapshot.Rows, rows...) + } + operationRows, err := s.capturePersonMergeOperationReferencesTx(ctx, tx, absorbedID) + if err != nil { + return personMergeSnapshot{}, err + } + snapshot.Rows = append(snapshot.Rows, operationRows...) + dependentRows, err := s.capturePersonMergeIdentityDependentsTx( + ctx, tx, snapshot.Rows, absorbedID, + ) + if err != nil { + return personMergeSnapshot{}, err + } + snapshot.Rows = append(snapshot.Rows, dependentRows...) + relationshipDependents, err := s.capturePersonMergeRelationshipDependentsTx( + ctx, tx, snapshot.Rows, absorbedID, + ) + if err != nil { + return personMergeSnapshot{}, err + } + snapshot.Rows = appendPersonMergeSnapshotRowsUnique(snapshot.Rows, relationshipDependents...) + sort.SliceStable(snapshot.Rows, func(i, j int) bool { + if snapshot.Rows[i].TableName != snapshot.Rows[j].TableName { + return snapshot.Rows[i].TableName < snapshot.Rows[j].TableName + } + if snapshot.Rows[i].RowID != snapshot.Rows[j].RowID { + return snapshot.Rows[i].RowID < snapshot.Rows[j].RowID + } + return snapshot.Rows[i].RowKey < snapshot.Rows[j].RowKey + }) + return snapshot, nil +} + +func (s *Store) capturePersonMergeRelationshipDependentsTx( + ctx context.Context, + tx *loggedTx, + primaryRows []personMergeSnapshotRow, + absorbedID int64, +) ([]personMergeSnapshotRow, error) { + relationshipOrigins := make(map[int64]personMergeOriginSide) + for _, row := range primaryRows { + if row.TableName == personRelationshipsTableName { + relationshipOrigins[row.RowID] = row.OriginSide + } + } + ids := sortedPersonMergeSnapshotIDs(relationshipOrigins) + if len(ids) == 0 { + return nil, nil + } + rows, err := s.capturePersonMergeQueryTx(ctx, tx, + personMergeTableRegistry[personRelationshipReviewsTableName], + `SELECT * FROM person_relationship_reviews + WHERE accepted_relationship_id IN (`+personMergeSnapshotPlaceholders(len(ids))+`) + ORDER BY id`, personMergeSnapshotIDArgs(ids), absorbedID) + if err != nil { + return nil, err + } + setDependentSnapshotOrigins(rows, relationshipOrigins, "accepted_relationship_id") + return rows, nil +} + +func appendPersonMergeSnapshotRowsUnique( + rows []personMergeSnapshotRow, extra ...personMergeSnapshotRow, +) []personMergeSnapshotRow { + seen := make(map[string]struct{}, len(rows)+len(extra)) + for _, row := range rows { + seen[row.TableName+"\x00"+row.RowKey] = struct{}{} + } + for _, row := range extra { + key := row.TableName + "\x00" + row.RowKey + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + rows = append(rows, row) + } + return rows +} + +func (s *Store) capturePersonMergeOperationReferencesTx( + ctx context.Context, tx *loggedTx, absorbedID int64, +) ([]personMergeSnapshotRow, error) { + queries := []struct { + table string + query string + }{ + { + table: "person_merges", + query: `SELECT id, current_person_id FROM person_merges + WHERE current_person_id = ? ORDER BY id`, + }, + { + table: personMergeReviewCandidatesTableName, + query: `SELECT id, survivor_person_id, state, reviewed_at + FROM person_merge_review_candidates + WHERE survivor_person_id = ? ORDER BY id`, + }, + } + result := []personMergeSnapshotRow{} + for _, item := range queries { + rows, err := s.capturePersonMergeQueryTx( + ctx, tx, personMergeTableRegistry[item.table], item.query, []any{absorbedID}, absorbedID, + ) + if err != nil { + return nil, err + } + result = append(result, rows...) + } + return result, nil +} + +func capturePersonMergeRootTx( + ctx context.Context, tx *loggedTx, personID int64, +) (personMergeSnapshotPerson, error) { + var ( + person personMergeSnapshotPerson + displayName sql.NullString + createdAt any + updatedAt any + ) + err := tx.QueryRowContext(ctx, `SELECT + id, vcard_uid, display_name, revision, vcard_projection_revision, + created_at, updated_at + FROM persons WHERE id = ?`, personID).Scan( + &person.ID, &person.VCardUID, &displayName, &person.Revision, + &person.VCardProjectionRevision, &createdAt, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return personMergeSnapshotPerson{}, ErrPersonNotFound + } + if err != nil { + return personMergeSnapshotPerson{}, fmt.Errorf("capture person %d root: %w", personID, err) + } + if displayName.Valid { + person.DisplayName = &displayName.String + } + person.CreatedAt = personMergeSnapshotTextValue(createdAt) + person.UpdatedAt = personMergeSnapshotTextValue(updatedAt) + person.ParticipantIDs = []int64{} + + rows, err := tx.QueryContext(ctx, `SELECT participant_id + FROM person_participants WHERE person_id = ? ORDER BY participant_id`, personID) + if err != nil { + return personMergeSnapshotPerson{}, fmt.Errorf("capture person %d bindings: %w", personID, err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var participantID int64 + if err := rows.Scan(&participantID); err != nil { + return personMergeSnapshotPerson{}, fmt.Errorf("scan person %d binding: %w", personID, err) + } + person.ParticipantIDs = append(person.ParticipantIDs, participantID) + } + if err := rows.Err(); err != nil { + return personMergeSnapshotPerson{}, fmt.Errorf("iterate person %d bindings: %w", personID, err) + } + return person, nil +} + +func (s *Store) capturePersonMergeTableTx( + ctx context.Context, + tx *loggedTx, + spec personMergeTableSpec, + survivorID, absorbedID int64, +) ([]personMergeSnapshotRow, error) { + predicates := make([]string, 0, len(spec.PersonReferences)) + args := make([]any, 0, len(spec.PersonReferences)*3) + keyColumns := spec.keyColumns() + if len(keyColumns) == 0 { + return nil, fmt.Errorf("capture %s: no stable key columns", spec.TableName) + } + orderColumns := append([]string(nil), keyColumns...) + for _, reference := range spec.PersonReferences { + switch reference.Kind { + case personMergeReferenceDirect: + predicates = append(predicates, + fmt.Sprintf("(%s = ? OR %s = ?)", reference.IDColumn, reference.IDColumn)) + args = append(args, survivorID, absorbedID) + case personMergeReferencePolymorphic: + predicates = append(predicates, fmt.Sprintf( + "(%s = ? AND (%s = ? OR %s = ?))", + reference.KindColumn, reference.IDColumn, reference.IDColumn)) + args = append(args, reference.KindValue, survivorID, absorbedID) + default: + return nil, fmt.Errorf("capture %s: unknown reference kind %q", spec.TableName, reference.Kind) + } + orderColumns = appendUniqueString(orderColumns, reference.IDColumn) + if reference.KindColumn != "" { + orderColumns = appendUniqueString(orderColumns, reference.KindColumn) + } + } + if len(predicates) == 0 { + return nil, fmt.Errorf("capture %s: no person reference", spec.TableName) + } + query := fmt.Sprintf("SELECT * FROM %s WHERE %s ORDER BY %s", + spec.TableName, strings.Join(predicates, " OR "), strings.Join(orderColumns, ", ")) + return s.capturePersonMergeQueryTx(ctx, tx, spec, query, args, absorbedID) +} + +func (s *Store) capturePersonMergeQueryTx( + ctx context.Context, + tx *loggedTx, + spec personMergeTableSpec, + query string, + args []any, + absorbedID int64, +) ([]personMergeSnapshotRow, error) { + keyColumns := spec.keyColumns() + if len(keyColumns) == 0 { + return nil, fmt.Errorf("capture %s: no stable key columns", spec.TableName) + } + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("capture %s rows: %w", spec.TableName, err) + } + defer func() { _ = rows.Close() }() + columns, err := rows.Columns() + if err != nil { + return nil, fmt.Errorf("read %s snapshot columns: %w", spec.TableName, err) + } + columnTypes, err := rows.ColumnTypes() + if err != nil { + return nil, fmt.Errorf("read %s snapshot column types: %w", spec.TableName, err) + } + indexes := make(map[string]int, len(columns)) + for i, column := range columns { + indexes[column] = i + } + + result := []personMergeSnapshotRow{} + for rows.Next() { + values := make([]any, len(columns)) + destinations := make([]any, len(columns)) + for i := range values { + destinations[i] = &values[i] + } + if err := rows.Scan(destinations...); err != nil { + return nil, fmt.Errorf("scan %s snapshot row: %w", spec.TableName, err) + } + row := personMergeSnapshotRow{ + TableName: spec.TableName, + OriginSide: personMergeRowOrigin(values, indexes, spec.PersonReferences, absorbedID), + ProvenanceKind: personMergeRowProvenance(spec.TableName, values, indexes, absorbedID), + Columns: make([]personMergeSnapshotColumn, 0, len(columns)), + } + for i, column := range columns { + value, normalizeErr := normalizePersonMergeSnapshotValue( + values[i], columnTypes[i].DatabaseTypeName(), + ) + if normalizeErr != nil { + return nil, fmt.Errorf("normalize %s.%s: %w", spec.TableName, column, normalizeErr) + } + row.Columns = append(row.Columns, personMergeSnapshotColumn{Name: column, Value: value}) + } + if len(keyColumns) == 1 { + if id, ok := personMergeSnapshotInt64(values[indexes[keyColumns[0]]]); ok { + row.RowID = id + } + } + row.RowKey, err = canonicalPersonMergeSnapshotRowKey(row.Columns, keyColumns) + if err != nil { + return nil, fmt.Errorf("key %s snapshot row: %w", spec.TableName, err) + } + result = append(result, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate %s snapshot rows: %w", spec.TableName, err) + } + return result, nil +} + +func personMergeRowProvenance( + table string, values []any, indexes map[string]int, absorbedID int64, +) personMergeProvenanceKind { + if table == personAttributeValuesTableName { + if index, ok := indexes["person_id"]; ok { + if id, valid := personMergeSnapshotInt64(values[index]); valid && id == absorbedID { + return personMergeProvenanceAbsorbedProfile + } + } + return personMergeProvenanceInboundReference + } + return personMergeTableProvenance(table) +} + +func canonicalPersonMergeSnapshotRowKey( + columns []personMergeSnapshotColumn, keyColumns []string, +) (string, error) { + byName := make(map[string]personMergeSnapshotValue, len(columns)) + for _, column := range columns { + byName[column.Name] = column.Value + } + key := make([]personMergeSnapshotColumn, 0, len(keyColumns)) + for _, name := range keyColumns { + value, ok := byName[name] + if !ok { + return "", fmt.Errorf("missing key column %q", name) + } + key = append(key, personMergeSnapshotColumn{Name: name, Value: value}) + } + encoded, err := json.Marshal(key) + if err != nil { + return "", fmt.Errorf("encode stable row key: %w", err) + } + return string(encoded), nil +} + +func (s *Store) capturePersonMergeIdentityDependentsTx( + ctx context.Context, + tx *loggedTx, + primaryRows []personMergeSnapshotRow, + absorbedID int64, +) ([]personMergeSnapshotRow, error) { + candidateOrigins := make(map[int64]personMergeOriginSide) + for _, row := range primaryRows { + if row.TableName == identityMatchCandidatesTableName { + candidateOrigins[row.RowID] = row.OriginSide + } + } + candidateIDs := sortedPersonMergeSnapshotIDs(candidateOrigins) + if len(candidateIDs) == 0 { + return nil, nil + } + candidateArgs := personMergeSnapshotIDArgs(candidateIDs) + candidatePlaceholders := personMergeSnapshotPlaceholders(len(candidateIDs)) + + result := []personMergeSnapshotRow{} + redirectArgs := append(append([]any(nil), candidateArgs...), candidateArgs...) + redirects, err := s.capturePersonMergeQueryTx(ctx, tx, + personMergeTableRegistry["identity_match_candidate_redirects"], + `SELECT * FROM identity_match_candidate_redirects + WHERE retired_candidate_id IN (`+candidatePlaceholders+`) + OR surviving_candidate_id IN (`+candidatePlaceholders+`) + ORDER BY retired_candidate_id`, redirectArgs, absorbedID) + if err != nil { + return nil, err + } + setDependentSnapshotOrigins(redirects, candidateOrigins, + "surviving_candidate_id", "retired_candidate_id") + result = append(result, redirects...) + + sources, err := s.capturePersonMergeQueryTx(ctx, tx, + personMergeTableRegistry[identityMatchCandidateSourcesTableName], + `SELECT * FROM identity_match_candidate_sources + WHERE candidate_id IN (`+candidatePlaceholders+`) + ORDER BY candidate_id, source_id`, candidateArgs, absorbedID) + if err != nil { + return nil, err + } + setDependentSnapshotOrigins(sources, candidateOrigins, "candidate_id") + result = append(result, sources...) + + evidenceRows, err := s.capturePersonMergeQueryTx(ctx, tx, + personMergeTableRegistry[identityMatchEvidenceTableName], + `SELECT * FROM identity_match_evidence + WHERE candidate_id IN (`+candidatePlaceholders+`) + ORDER BY id`, candidateArgs, absorbedID) + if err != nil { + return nil, err + } + setDependentSnapshotOrigins(evidenceRows, candidateOrigins, "candidate_id") + result = append(result, evidenceRows...) + + evidenceOrigins := make(map[int64]personMergeOriginSide, len(evidenceRows)) + for _, row := range evidenceRows { + evidenceOrigins[row.RowID] = row.OriginSide + } + evidenceIDs := sortedPersonMergeSnapshotIDs(evidenceOrigins) + if len(evidenceIDs) == 0 { + return result, nil + } + evidenceArgs := personMergeSnapshotIDArgs(evidenceIDs) + evidenceSources, err := s.capturePersonMergeQueryTx(ctx, tx, + personMergeTableRegistry[identityMatchEvidenceSourcesTableName], + `SELECT * FROM identity_match_evidence_sources + WHERE evidence_id IN (`+personMergeSnapshotPlaceholders(len(evidenceIDs))+`) + ORDER BY evidence_id, source_id`, evidenceArgs, absorbedID) + if err != nil { + return nil, err + } + setDependentSnapshotOrigins(evidenceSources, evidenceOrigins, "evidence_id") + result = append(result, evidenceSources...) + return result, nil +} + +func setDependentSnapshotOrigins( + rows []personMergeSnapshotRow, + origins map[int64]personMergeOriginSide, + columns ...string, +) { + for i := range rows { + for _, column := range columns { + id, ok := personMergeSnapshotRowInteger(rows[i], column) + if !ok { + continue + } + if origin, exists := origins[id]; exists { + rows[i].OriginSide = origin + break + } + } + } +} + +func personMergeSnapshotRowInteger(row personMergeSnapshotRow, name string) (int64, bool) { + for _, column := range row.Columns { + if column.Name == name && column.Value.Integer != nil { + return *column.Value.Integer, true + } + } + return 0, false +} + +func sortedPersonMergeSnapshotIDs(origins map[int64]personMergeOriginSide) []int64 { + ids := make([]int64, 0, len(origins)) + for id := range origins { + ids = append(ids, id) + } + slices.Sort(ids) + return ids +} + +func personMergeSnapshotIDArgs(ids []int64) []any { + args := make([]any, len(ids)) + for i, id := range ids { + args[i] = id + } + return args +} + +func personMergeSnapshotPlaceholders(count int) string { + return strings.TrimSuffix(strings.Repeat("?,", count), ",") +} + +func personMergeRowOrigin( + values []any, + indexes map[string]int, + references []personMergeReference, + absorbedID int64, +) personMergeOriginSide { + for _, reference := range references { + if reference.Kind == personMergeReferencePolymorphic && + personMergeSnapshotTextValue(values[indexes[reference.KindColumn]]) != reference.KindValue { + continue + } + if id, ok := personMergeSnapshotInt64(values[indexes[reference.IDColumn]]); ok && id == absorbedID { + return personMergeOriginAbsorbed + } + } + return personMergeOriginSurvivor +} + +func personMergeTableProvenance(table string) personMergeProvenanceKind { + switch table { + case "activity_event_persons", "person_contact_state": + return personMergeProvenanceDerived + case "organization_attribute_values", "person_uid_aliases", personRelationshipsTableName, + personRelationshipReviewsTableName, identityMatchCandidatesTableName, + "identity_match_candidate_redirects", identityMatchCandidateSourcesTableName, + identityMatchEvidenceTableName, identityMatchEvidenceSourcesTableName, "person_merges", + personMergeReviewCandidatesTableName, "daily_note_entry_persons": + return personMergeProvenanceInboundReference + default: + return personMergeProvenanceAbsorbedProfile + } +} + +func normalizePersonMergeSnapshotValue(raw any, databaseType string) (personMergeSnapshotValue, error) { + if raw == nil { + return personMergeSnapshotValue{Kind: personMergeSnapshotNull}, nil + } + databaseType = strings.ToUpper(databaseType) + switch { + case strings.Contains(databaseType, "BOOL"): + value, err := personMergeSnapshotBool(raw) + return personMergeSnapshotValue{Kind: personMergeSnapshotBoolean, Boolean: &value}, err + case strings.Contains(databaseType, "INT"): + value, ok := personMergeSnapshotInt64(raw) + if !ok { + return personMergeSnapshotValue{}, fmt.Errorf("%T is not an integer", raw) + } + return personMergeSnapshotValue{Kind: personMergeSnapshotInteger, Integer: &value}, nil + case strings.Contains(databaseType, "REAL") || strings.Contains(databaseType, "FLOA") || + strings.Contains(databaseType, "DOUBL") || strings.Contains(databaseType, "NUMERIC"): + value, err := personMergeSnapshotFloat64(raw) + return personMergeSnapshotValue{Kind: personMergeSnapshotReal, Real: &value}, err + case strings.Contains(databaseType, "BLOB") || strings.Contains(databaseType, "BYTEA"): + value, err := personMergeSnapshotBytesValue(raw) + return personMergeSnapshotValue{Kind: personMergeSnapshotBytes, Bytes: value}, err + default: + value := personMergeSnapshotTextValue(raw) + if strings.Contains(databaseType, "JSON") { + var decoded any + decoder := json.NewDecoder(strings.NewReader(value)) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return personMergeSnapshotValue{}, err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + err = errors.New("multiple JSON values") + } + return personMergeSnapshotValue{}, err + } + canonical, err := json.Marshal(decoded) + if err != nil { + return personMergeSnapshotValue{}, err + } + value = string(canonical) + } + return personMergeSnapshotValue{Kind: personMergeSnapshotText, Text: &value}, nil + } +} + +func personMergeSnapshotInt64(raw any) (int64, bool) { + switch value := raw.(type) { + case int64: + return value, true + case int32: + return int64(value), true + case int: + return int64(value), true + case bool: + if value { + return 1, true + } + return 0, true + case []byte: + parsed, err := strconv.ParseInt(string(value), 10, 64) + return parsed, err == nil + case string: + parsed, err := strconv.ParseInt(value, 10, 64) + return parsed, err == nil + default: + return 0, false + } +} + +func personMergeSnapshotBool(raw any) (bool, error) { + if value, ok := raw.(bool); ok { + return value, nil + } + if value, ok := personMergeSnapshotInt64(raw); ok { + return value != 0, nil + } + parsed, err := strconv.ParseBool(personMergeSnapshotTextValue(raw)) + if err != nil { + return false, fmt.Errorf("parse snapshot boolean: %w", err) + } + return parsed, nil +} + +func personMergeSnapshotFloat64(raw any) (float64, error) { + switch value := raw.(type) { + case float64: + return value, nil + case float32: + return float64(value), nil + case int64: + return float64(value), nil + case []byte: + parsed, err := strconv.ParseFloat(string(value), 64) + if err != nil { + return 0, fmt.Errorf("parse snapshot float bytes: %w", err) + } + return parsed, nil + case string: + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("parse snapshot float: %w", err) + } + return parsed, nil + default: + return 0, fmt.Errorf("%T is not a real number", raw) + } +} + +func personMergeSnapshotBytesValue(raw any) ([]byte, error) { + switch value := raw.(type) { + case []byte: + return append([]byte(nil), value...), nil + case string: + return []byte(value), nil + default: + return nil, fmt.Errorf("%T is not bytes", raw) + } +} + +func personMergeSnapshotTextValue(raw any) string { + switch value := raw.(type) { + case string: + return value + case []byte: + return string(value) + case time.Time: + return value.UTC().Format(time.RFC3339Nano) + case nil: + return "" + default: + return fmt.Sprint(value) + } +} + +func appendUniqueString(values []string, value string) []string { + if slices.Contains(values, value) { + return values + } + return append(values, value) +} + +func encodePersonMergeSnapshot(snapshot personMergeSnapshot) ([]byte, string, error) { + if snapshot.Version != personMergeSnapshotVersion { + return nil, "", fmt.Errorf("%w: unsupported snapshot version %d", + ErrPersonMergeInvalid, snapshot.Version) + } + canonical, err := json.Marshal(snapshot) + if err != nil { + return nil, "", fmt.Errorf("marshal person merge snapshot: %w", err) + } + digest := sha256.Sum256(canonical) + + var compressed bytes.Buffer + writer, err := zlib.NewWriterLevel(&compressed, zlib.BestCompression) + if err != nil { + return nil, "", fmt.Errorf("create person merge snapshot compressor: %w", err) + } + if _, err := writer.Write(canonical); err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("compress person merge snapshot: %w", err) + } + if err := writer.Close(); err != nil { + return nil, "", fmt.Errorf("finish person merge snapshot compression: %w", err) + } + return compressed.Bytes(), hex.EncodeToString(digest[:]), nil +} + +func decodePersonMergeSnapshot(compressed []byte, wantSHA256 string) (personMergeSnapshot, error) { + reader, err := zlib.NewReader(bytes.NewReader(compressed)) + if err != nil { + return personMergeSnapshot{}, fmt.Errorf("%w: open zlib stream: %w", + ErrPersonMergeSnapshotCorrupt, err) + } + canonical, readErr := io.ReadAll(reader) + closeErr := reader.Close() + if readErr != nil { + return personMergeSnapshot{}, fmt.Errorf("%w: read zlib stream: %w", + ErrPersonMergeSnapshotCorrupt, readErr) + } + if closeErr != nil { + return personMergeSnapshot{}, fmt.Errorf("%w: close zlib stream: %w", + ErrPersonMergeSnapshotCorrupt, closeErr) + } + digest := sha256.Sum256(canonical) + if hex.EncodeToString(digest[:]) != wantSHA256 { + return personMergeSnapshot{}, fmt.Errorf("%w: SHA-256 mismatch", + ErrPersonMergeSnapshotCorrupt) + } + + var snapshot personMergeSnapshot + if err := json.Unmarshal(canonical, &snapshot); err != nil { + return personMergeSnapshot{}, fmt.Errorf("%w: decode JSON: %w", + ErrPersonMergeSnapshotCorrupt, err) + } + if snapshot.Version != personMergeSnapshotVersion { + return personMergeSnapshot{}, fmt.Errorf("%w: unsupported version %d", + ErrPersonMergeSnapshotCorrupt, snapshot.Version) + } + return snapshot, nil +} diff --git a/internal/store/person_merge_snapshot_test.go b/internal/store/person_merge_snapshot_test.go new file mode 100644 index 000000000..58e557619 --- /dev/null +++ b/internal/store/person_merge_snapshot_test.go @@ -0,0 +1,276 @@ +package store + +import ( + "bytes" + "compress/zlib" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCapturePersonMergeSnapshotIncludesRootsBindingsAndReferencedRows(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st, err := Open(filepath.Join(t.TempDir(), "capture.db")) + require.NoError(err) + t.Cleanup(func() { _ = st.Close() }) + require.NoError(st.InitSchema()) + + survivorParticipant, err := st.EnsureParticipant("survivor@example.com", "Survivor", "example.com") + require.NoError(err) + absorbedParticipant, err := st.EnsureParticipant("absorbed@example.com", "Absorbed", "example.com") + require.NoError(err) + survivor, created, err := st.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + require.True(created) + absorbed, created, err := st.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + require.True(created) + + _, err = st.AddPersonNameContext(context.Background(), survivor.ID, PersonNameInput{ + NameKind: PersonNameFormatted, Formatted: new("Survivor Name"), + Envelope: ValueEnvelopeInput{Source: ProvenanceUser}, + }) + require.NoError(err) + _, err = st.AddPersonNameContext(context.Background(), absorbed.ID, PersonNameInput{ + NameKind: PersonNameFormatted, Formatted: new("Absorbed Name"), + Envelope: ValueEnvelopeInput{Source: ProvenanceVCardImport, SourceRef: new("card-2")}, + }) + require.NoError(err) + _, err = st.RetirePersonUIDAliasContext(context.Background(), "retired-before-merge", &survivor.ID, "test") + require.NoError(err) + source, err := st.GetOrCreateSource("gmail", "snapshot@example.com") + require.NoError(err) + candidate, _, err := st.UpsertIdentityMatchCandidateContext(context.Background(), IdentityMatchCandidateInput{ + LeftKind: IdentityMatchPerson, LeftID: survivor.ID, + RightKind: IdentityMatchPerson, RightID: absorbed.ID, + Basis: IdentityMatchDisplayName, State: IdentityMatchStateCandidate, + Source: ProvenanceUser, SourceID: &source.ID, + }) + require.NoError(err) + evidence, err := st.AddIdentityMatchEvidenceContext(context.Background(), candidate.ID, IdentityMatchEvidenceInput{ + EvidenceKind: "shared_name", Source: ProvenanceUser, SourceID: &source.ID, + }) + require.NoError(err) + _, err = st.db.Exec(`INSERT INTO identity_match_candidate_redirects + (retired_candidate_id, surviving_candidate_id, endpoints_collapsed) + VALUES (?, ?, FALSE)`, candidate.ID+1000, candidate.ID) + require.NoError(err) + + var snapshot personMergeSnapshot + require.NoError(st.withTxContext(context.Background(), func(tx *loggedTx) error { + var captureErr error + snapshot, captureErr = st.capturePersonMergeSnapshotTx( + context.Background(), tx, survivor.ID, absorbed.ID, + ) + return captureErr + })) + + require.Len(snapshot.Persons, 2) + assert.Equal(survivor.ID, snapshot.Persons[0].ID) + assert.Equal([]int64{survivorParticipant}, snapshot.Persons[0].ParticipantIDs) + assert.Equal(absorbed.ID, snapshot.Persons[1].ID) + assert.Equal([]int64{absorbedParticipant}, snapshot.Persons[1].ParticipantIDs) + + rowsByTable := make(map[string][]personMergeSnapshotRow) + for _, row := range snapshot.Rows { + rowsByTable[row.TableName] = append(rowsByTable[row.TableName], row) + } + assert.Len(rowsByTable["person_names"], 2) + assert.Len(rowsByTable["person_uid_aliases"], 1) + assert.Len(rowsByTable["identity_match_candidates"], 1) + assert.Len(rowsByTable["identity_match_candidate_redirects"], 1) + assert.Len(rowsByTable["identity_match_candidate_sources"], 1) + require.Len(rowsByTable["identity_match_evidence"], 1) + assert.Len(rowsByTable["identity_match_evidence_sources"], 1) + assert.Equal(evidence.ID, rowsByTable["identity_match_evidence"][0].RowID) + assert.Equal(personMergeOriginSurvivor, rowsByTable["person_names"][0].OriginSide) + assert.Equal(personMergeOriginAbsorbed, rowsByTable["person_names"][1].OriginSide) + assert.NotEmpty(rowsByTable["person_names"][0].RowKey, + "numeric primary keys need a stable journal key") + assert.NotEmpty(rowsByTable["person_uid_aliases"][0].RowKey, + "text primary keys need a stable journal key") + assert.NotEmpty(rowsByTable["identity_match_candidate_sources"][0].RowKey, + "composite primary keys need a stable journal key") + + firstCompressed, firstHash, err := encodePersonMergeSnapshot(snapshot) + require.NoError(err) + secondCompressed, secondHash, err := encodePersonMergeSnapshot(snapshot) + require.NoError(err) + assert.Equal(firstHash, secondHash) + assert.Equal(firstCompressed, secondCompressed) +} + +func TestPersonMergeTableInventoryClassifiesEveryPersonReference(t *testing.T) { + require := require.New(t) + st, err := Open(filepath.Join(t.TempDir(), "inventory.db")) + require.NoError(err) + t.Cleanup(func() { _ = st.Close() }) + require.NoError(st.InitSchema()) + assertPersonMergeTableInventory(t, st) +} + +func TestPostgresPersonMergeTableInventoryClassifiesEveryPersonReference(t *testing.T) { + dbURL := skipUnlessPostgresInternal(t) + assertPersonMergeTableInventory(t, newPGStoreInternal(t, dbURL)) +} + +func assertPersonMergeTableInventory(t *testing.T, st *Store) { + t.Helper() + require := require.New(t) + assert := assert.New(t) + + actual := make([]string, 0) + query := `SELECT child.name, foreign_key."from" + FROM sqlite_master child + JOIN pragma_foreign_key_list(child.name) foreign_key + WHERE child.type = 'table' AND child.name NOT LIKE 'sqlite_%' + AND foreign_key."table" = 'persons' + ORDER BY child.name, foreign_key."from"` + if st.IsPostgreSQL() { + query = `SELECT constraints.table_name, columns.column_name + FROM information_schema.table_constraints constraints + JOIN information_schema.key_column_usage columns + ON columns.constraint_catalog = constraints.constraint_catalog + AND columns.constraint_schema = constraints.constraint_schema + AND columns.constraint_name = constraints.constraint_name + JOIN information_schema.constraint_column_usage target + ON target.constraint_catalog = constraints.constraint_catalog + AND target.constraint_schema = constraints.constraint_schema + AND target.constraint_name = constraints.constraint_name + WHERE constraints.constraint_type = 'FOREIGN KEY' + AND constraints.table_schema = current_schema() + AND target.table_schema = current_schema() + AND target.table_name = 'persons' + ORDER BY constraints.table_name, columns.column_name` + } + rows, err := st.db.Query(query) + require.NoError(err) + for rows.Next() { + var table, column string + require.NoError(rows.Scan(&table, &column)) + actual = append(actual, table+"."+column) + } + require.NoError(rows.Err()) + require.NoError(rows.Close()) + sort.Strings(actual) + + classified := make([]string, 0) + for table, spec := range personMergeTableRegistry { + assert.Equal(table, spec.TableName) + assert.NotEmpty(spec.KeyColumn, "key column for %s", table) + for _, reference := range spec.PersonReferences { + if reference.Kind == personMergeReferenceDirect { + classified = append(classified, table+"."+reference.IDColumn) + } + } + } + sort.Strings(classified) + assert.Equal(actual, classified, + "every live foreign key to persons must have an explicit merge policy") + + for _, want := range []struct { + table, idColumn, kindColumn, kindValue string + }{ + {table: "person_attribute_values", idColumn: "value_record_id", kindColumn: "value_record_type", kindValue: "person"}, + {table: "organization_attribute_values", idColumn: "value_record_id", kindColumn: "value_record_type", kindValue: "person"}, + {table: "identity_match_candidates", idColumn: "left_id", kindColumn: "left_kind", kindValue: "person"}, + {table: "identity_match_candidates", idColumn: "right_id", kindColumn: "right_kind", kindValue: "person"}, + } { + spec, ok := personMergeTableRegistry[want.table] + require.True(ok, "polymorphic person table %s is classified", want.table) + assert.Contains(spec.PersonReferences, personMergeReference{ + Kind: personMergeReferencePolymorphic, IDColumn: want.idColumn, + KindColumn: want.kindColumn, KindValue: want.kindValue, + }, "polymorphic reference %s.%s", want.table, want.idColumn) + } +} + +func TestPersonMergeSnapshotCodecIsDeterministicAndDetectsCorruption(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + displayName := "Survivor" + participantID := int64(31) + snapshot := personMergeSnapshot{ + Version: personMergeSnapshotVersion, + Persons: []personMergeSnapshotPerson{{ + ID: 7, VCardUID: "uid-7", DisplayName: &displayName, + Revision: 2, VCardProjectionRevision: 4, + CreatedAt: "2026-08-18T12:00:00Z", UpdatedAt: "2026-08-19T01:02:03Z", + ParticipantIDs: []int64{31, 32}, + }}, + Rows: []personMergeSnapshotRow{{ + TableName: "person_media", RowID: 9, OriginSide: personMergeOriginSurvivor, + ProvenanceKind: personMergeProvenanceParticipantExact, + ParticipantID: &participantID, + Columns: []personMergeSnapshotColumn{ + {Name: "id", Value: personMergeSnapshotValue{Kind: personMergeSnapshotInteger, Integer: new(int64(9))}}, + {Name: "content_hash", Value: personMergeSnapshotValue{Kind: personMergeSnapshotText, Text: new("sha256:abc")}}, + {Name: "inline_data", Value: personMergeSnapshotValue{Kind: personMergeSnapshotBytes, Bytes: []byte{0, 1, 2, 255}}}, + {Name: "active_until", Value: personMergeSnapshotValue{Kind: personMergeSnapshotNull}}, + }, + }}, + } + + firstCompressed, firstHash, err := encodePersonMergeSnapshot(snapshot) + require.NoError(err) + secondCompressed, secondHash, err := encodePersonMergeSnapshot(snapshot) + require.NoError(err) + assert.Equal(firstHash, secondHash) + assert.True(bytes.Equal(firstCompressed, secondCompressed), "zlib output must be deterministic") + + decoded, err := decodePersonMergeSnapshot(firstCompressed, firstHash) + require.NoError(err) + assert.Equal(snapshot, decoded) + + badHash := firstHash[:63] + "0" + if badHash == firstHash { + badHash = firstHash[:63] + "1" + } + _, err = decodePersonMergeSnapshot(firstCompressed, badHash) + require.Error(err) + require.ErrorIs(err, ErrPersonMergeSnapshotCorrupt) + + corrupt := append([]byte(nil), firstCompressed...) + corrupt[len(corrupt)/2] ^= 0xff + _, err = decodePersonMergeSnapshot(corrupt, firstHash) + require.Error(err) + require.ErrorIs(err, ErrPersonMergeSnapshotCorrupt) + + unknownVersion := snapshot + unknownVersion.Version++ + unknownCompressed, unknownHash := encodeUncheckedPersonMergeSnapshot(t, unknownVersion) + _, err = decodePersonMergeSnapshot(unknownCompressed, unknownHash) + require.ErrorIs(err, ErrPersonMergeSnapshotCorrupt) +} + +func encodeUncheckedPersonMergeSnapshot( + t *testing.T, snapshot personMergeSnapshot, +) ([]byte, string) { + t.Helper() + canonical, err := json.Marshal(snapshot) + require.NoError(t, err) + digest := sha256.Sum256(canonical) + var compressed bytes.Buffer + writer := zlib.NewWriter(&compressed) + _, err = writer.Write(canonical) + require.NoError(t, err) + require.NoError(t, writer.Close()) + return compressed.Bytes(), hex.EncodeToString(digest[:]) +} + +func TestNormalizePersonMergeSnapshotJSONPreservesLargeIntegers(t *testing.T) { + value, err := normalizePersonMergeSnapshotValue( + []byte(`{"large":9007199254740993,"small":1}`), "JSON", + ) + require.NoError(t, err) + require.NotNil(t, value.Text) + assert.Equal(t, `{"large":9007199254740993,"small":1}`, *value.Text) +} diff --git a/internal/store/person_merge_validation_internal_test.go b/internal/store/person_merge_validation_internal_test.go new file mode 100644 index 000000000..c25ac1b5e --- /dev/null +++ b/internal/store/person_merge_validation_internal_test.go @@ -0,0 +1,88 @@ +package store + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPersonMergeRequestValidation(t *testing.T) { + valid := PersonMergeRequest{ + SurvivorID: 1, + AbsorbedID: 2, + ExpectedSurvivorRevision: 3, + ExpectedAbsorbedRevision: 4, + IdempotencyKey: "merge-1-2", + Actor: "test", + } + tests := []struct { + name string + mutate func(*PersonMergeRequest) + }{ + {name: "survivor id", mutate: func(r *PersonMergeRequest) { r.SurvivorID = 0 }}, + {name: "absorbed id", mutate: func(r *PersonMergeRequest) { r.AbsorbedID = 0 }}, + {name: "same person", mutate: func(r *PersonMergeRequest) { r.AbsorbedID = r.SurvivorID }}, + {name: "survivor revision", mutate: func(r *PersonMergeRequest) { r.ExpectedSurvivorRevision = 0 }}, + {name: "absorbed revision", mutate: func(r *PersonMergeRequest) { r.ExpectedAbsorbedRevision = 0 }}, + {name: "empty key", mutate: func(r *PersonMergeRequest) { r.IdempotencyKey = " " }}, + {name: "oversized key", mutate: func(r *PersonMergeRequest) { r.IdempotencyKey = strings.Repeat("x", 129) }}, + {name: "empty actor", mutate: func(r *PersonMergeRequest) { r.Actor = " " }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + request := valid + tc.mutate(&request) + err := request.validate() + require.Error(t, err) + assert.ErrorIs(t, err, ErrPersonMergeInvalid) + }) + } + require.NoError(t, valid.validate()) +} + +func TestPersonSplitRequestValidation(t *testing.T) { + valid := PersonSplitRequest{ + SourcePersonID: 1, + MergeID: 2, + ParticipantIDs: []int64{4, 3}, + ExpectedSourceRevision: 5, + IdempotencyKey: "split-2-3-4", + Actor: "test", + } + tests := []struct { + name string + mutate func(*PersonSplitRequest) + }{ + {name: "source id", mutate: func(r *PersonSplitRequest) { r.SourcePersonID = 0 }}, + {name: "merge id", mutate: func(r *PersonSplitRequest) { r.MergeID = 0 }}, + {name: "invalid participant", mutate: func(r *PersonSplitRequest) { r.ParticipantIDs = []int64{0} }}, + {name: "duplicate participant", mutate: func(r *PersonSplitRequest) { r.ParticipantIDs = []int64{3, 3} }}, + {name: "source revision", mutate: func(r *PersonSplitRequest) { r.ExpectedSourceRevision = 0 }}, + {name: "empty key", mutate: func(r *PersonSplitRequest) { r.IdempotencyKey = "" }}, + {name: "oversized key", mutate: func(r *PersonSplitRequest) { r.IdempotencyKey = strings.Repeat("x", 129) }}, + {name: "empty actor", mutate: func(r *PersonSplitRequest) { r.Actor = "" }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + request := valid + request.ParticipantIDs = append([]int64(nil), valid.ParticipantIDs...) + tc.mutate(&request) + err := request.validate() + require.Error(err) + assert.ErrorIs(err, ErrPersonMergeInvalid) + }) + } + + require := require.New(t) + assert := assert.New(t) + require.NoError(valid.validate()) + rootOnly := valid + rootOnly.ParticipantIDs = nil + require.NoError(rootOnly.validate()) + assert.Equal([]int64{3, 4}, valid.canonicalParticipantIDs()) + assert.Equal([]int64{4, 3}, valid.ParticipantIDs, "canonicalization must not mutate caller input") +} diff --git a/internal/store/person_merges.go b/internal/store/person_merges.go new file mode 100644 index 000000000..e2e2e6da1 --- /dev/null +++ b/internal/store/person_merges.go @@ -0,0 +1,2230 @@ +package store + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" +) + +const maxPersonOperationIdempotencyKeyBytes = 128 + +var ( + ErrPersonMergeNotFound = errors.New("person merge not found") + ErrPersonSplitNotFound = errors.New("person split not found") + ErrPersonMergeInvalid = errors.New("invalid person merge") + ErrPersonMergeAlreadySplit = errors.New("person merge lineage already split") + ErrPersonMergeLineageConflict = errors.New("participant consolidation crosses person merge lineage") + ErrPersonMergeIdempotency = errors.New("person merge idempotency conflict") + ErrPersonMergeCandidateState = errors.New("person merge candidate state conflict") + ErrPersonMergeCandidateNotFound = errors.New("person merge candidate not found") + ErrPersonSplitRevision = errors.New("person split revision conflict") + ErrPersonSplitIdempotency = errors.New("person split idempotency conflict") + ErrPersonSplitParticipants = errors.New("invalid person split participants") + ErrPersonSplitOwnership = errors.New("person merge is not owned by source person") + ErrPersonSplitReviewed = errors.New("person merge has accepted review candidates") +) + +// PersonMergeRequest identifies the surviving and absorbed profiles and the +// exact revisions the caller reviewed before requesting a merge. +type PersonMergeRequest struct { + SurvivorID int64 + AbsorbedID int64 + ExpectedSurvivorRevision int64 + ExpectedAbsorbedRevision int64 + IdempotencyKey string + Actor string +} + +func (r PersonMergeRequest) validate() error { + switch { + case r.SurvivorID <= 0: + return fmt.Errorf("%w: survivor person ID must be positive", ErrPersonMergeInvalid) + case r.AbsorbedID <= 0: + return fmt.Errorf("%w: absorbed person ID must be positive", ErrPersonMergeInvalid) + case r.SurvivorID == r.AbsorbedID: + return fmt.Errorf("%w: survivor and absorbed person must differ", ErrPersonMergeInvalid) + case r.ExpectedSurvivorRevision <= 0: + return fmt.Errorf("%w: survivor revision must be positive", ErrPersonMergeInvalid) + case r.ExpectedAbsorbedRevision <= 0: + return fmt.Errorf("%w: absorbed revision must be positive", ErrPersonMergeInvalid) + case strings.TrimSpace(r.IdempotencyKey) == "": + return fmt.Errorf("%w: idempotency key is required", ErrPersonMergeInvalid) + case len(r.IdempotencyKey) > maxPersonOperationIdempotencyKeyBytes: + return fmt.Errorf("%w: idempotency key exceeds %d bytes", + ErrPersonMergeInvalid, maxPersonOperationIdempotencyKeyBytes) + case strings.TrimSpace(r.Actor) == "": + return fmt.Errorf("%w: actor is required", ErrPersonMergeInvalid) + default: + return nil + } +} + +// PersonSplitRequest restores a merged profile into a newly created person. +// ParticipantIDs selects absorbed-origin lineages; it may be empty when the +// absorbed profile had no participants. +type PersonSplitRequest struct { + SourcePersonID int64 + MergeID int64 + ParticipantIDs []int64 + ExpectedSourceRevision int64 + IdempotencyKey string + Actor string +} + +func (r PersonSplitRequest) validate() error { + switch { + case r.SourcePersonID <= 0: + return fmt.Errorf("%w: source person ID must be positive", ErrPersonMergeInvalid) + case r.MergeID <= 0: + return fmt.Errorf("%w: merge ID must be positive", ErrPersonMergeInvalid) + case r.ExpectedSourceRevision <= 0: + return fmt.Errorf("%w: source revision must be positive", ErrPersonMergeInvalid) + case strings.TrimSpace(r.IdempotencyKey) == "": + return fmt.Errorf("%w: idempotency key is required", ErrPersonMergeInvalid) + case len(r.IdempotencyKey) > maxPersonOperationIdempotencyKeyBytes: + return fmt.Errorf("%w: idempotency key exceeds %d bytes", + ErrPersonMergeInvalid, maxPersonOperationIdempotencyKeyBytes) + case strings.TrimSpace(r.Actor) == "": + return fmt.Errorf("%w: actor is required", ErrPersonMergeInvalid) + } + + seen := make(map[int64]struct{}, len(r.ParticipantIDs)) + for _, participantID := range r.ParticipantIDs { + if participantID <= 0 { + return fmt.Errorf("%w: participant IDs must be positive", ErrPersonMergeInvalid) + } + if _, duplicate := seen[participantID]; duplicate { + return fmt.Errorf("%w: duplicate participant ID %d", ErrPersonMergeInvalid, participantID) + } + seen[participantID] = struct{}{} + } + return nil +} + +func (r PersonSplitRequest) canonicalParticipantIDs() []int64 { + ids := append([]int64(nil), r.ParticipantIDs...) + slices.Sort(ids) + return ids +} + +// PersonMerge is the immutable merge header plus its current live lineage +// owner. Historical IDs are retained even after their person roots disappear. +type PersonMerge struct { + ID int64 `json:"id"` + SurvivorPersonID int64 `json:"survivor_person_id"` + AbsorbedPersonID int64 `json:"absorbed_person_id"` + CurrentPersonID *int64 `json:"current_person_id,omitempty"` + SurvivorVCardUID string `json:"survivor_vcard_uid"` + AbsorbedVCardUID string `json:"absorbed_vcard_uid"` + SurvivorRevisionBefore int64 `json:"survivor_revision_before"` + AbsorbedRevisionBefore int64 `json:"absorbed_revision_before"` + SurvivorRevisionAfter int64 `json:"survivor_revision_after"` + Actor string `json:"actor"` + SnapshotVersion int `json:"snapshot_version"` + SnapshotSHA256 string `json:"snapshot_sha256"` + CreatedAt time.Time `json:"created_at"` +} + +// PersonMergeReviewCandidate retains both sides of a conflicting +// single-cardinality attribute until a user accepts or rejects the candidate. +type PersonMergeReviewCandidate struct { + ID int64 `json:"id"` + MergeID int64 `json:"merge_id"` + PersonID int64 `json:"person_id"` + DefinitionID int64 `json:"definition_id"` + SurvivorValueID int64 `json:"survivor_value_id"` + AbsorbedValueID int64 `json:"absorbed_value_id"` + State string `json:"state"` + ResolutionValueID *int64 `json:"resolution_value_id,omitempty"` + ReviewedBy *string `json:"reviewed_by,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// PersonSplit is the append-only operation header for one lineage split. +type PersonSplit struct { + ID int64 `json:"id"` + MergeID int64 `json:"merge_id"` + SourcePersonID int64 `json:"source_person_id"` + NewPersonID int64 `json:"new_person_id"` + NewPersonUID string `json:"new_person_uid"` + SourceRevisionBefore int64 `json:"source_revision_before"` + SourceRevisionAfter int64 `json:"source_revision_after"` + Actor string `json:"actor"` + ExactReversal bool `json:"exact_reversal"` + CreatedAt time.Time `json:"created_at"` +} + +// PersonMergeRowRef identifies an operation-journal row without exposing its +// snapshot payload. +type PersonMergeRowRef struct { + TableName string `json:"table_name"` + OriginalRowID *int64 `json:"original_row_id,omitempty"` + OriginalKey string `json:"original_row_key"` + Action string `json:"action"` +} + +// PersonSplitResult is the committed split plus both resulting people and +// any aggregate rows deliberately left on the source by a partial split. +type PersonSplitResult struct { + Split PersonSplit `json:"split"` + SourcePerson Person `json:"source_person"` + NewPerson Person `json:"new_person"` + ExactReversal bool `json:"exact_reversal"` + UIDAliasDisposition string `json:"uid_alias_disposition"` + AmbiguousRows []PersonMergeRowRef `json:"ambiguous_rows"` + UnrestoredRows []PersonMergeRowRef `json:"unrestored_rows"` + IdentityRevision int64 `json:"identity_revision"` + CacheState string `json:"cache_state" enum:"ready,stale"` +} + +// PersonMergeResult is the committed survivor and its durable operation +// record. ReviewCandidates is empty unless single-cardinality facts conflicted. +type PersonMergeResult struct { + Person Person `json:"person"` + Merge PersonMerge `json:"merge"` + ReviewCandidates []PersonMergeReviewCandidate `json:"review_candidates"` + IdentityRevision int64 `json:"identity_revision"` + CacheState string `json:"cache_state" enum:"ready,stale"` +} + +type PersonMergeParticipant struct { + MergeID int64 `json:"merge_id"` + ParticipantID int64 `json:"participant_id"` + OriginSide string `json:"origin_side"` + SplitID *int64 `json:"split_id,omitempty"` +} + +type PersonMergeRow struct { + MergeID int64 `json:"merge_id"` + TableName string `json:"table_name"` + OriginalRowID *int64 `json:"original_row_id,omitempty"` + OriginalRowKey string `json:"original_row_key"` + CurrentRowID *int64 `json:"current_row_id,omitempty"` + CurrentRowKey *string `json:"current_row_key,omitempty"` + OriginSide string `json:"origin_side"` + ProvenanceKind string `json:"provenance_kind"` + ParticipantID *int64 `json:"participant_id,omitempty"` + Action string `json:"action"` + SnapshotPath string `json:"snapshot_path"` + SplitID *int64 `json:"split_id,omitempty"` +} + +type PersonMergeSummary struct { + Merge PersonMerge `json:"merge"` + ParticipantCount int `json:"participant_count"` + RowCount int `json:"row_count"` + SplitCount int `json:"split_count"` + PendingCandidateCount int `json:"pending_candidate_count"` + RowActionCounts map[string]int `json:"row_action_counts"` +} + +type PersonMergeDetail struct { + Merge PersonMerge `json:"merge"` + Participants []PersonMergeParticipant `json:"participants"` + Rows []PersonMergeRow `json:"rows"` + Splits []PersonSplit `json:"splits"` + ReviewCandidates []PersonMergeReviewCandidate `json:"review_candidates"` +} + +type PersonMergeSnapshotResponse struct { + Version int `json:"version"` + SHA256 string `json:"sha256"` + JSON json.RawMessage `json:"snapshot"` +} + +type PersonMergeCandidateDecision string + +const ( + PersonMergeCandidateAccept PersonMergeCandidateDecision = "accept" + PersonMergeCandidateReject PersonMergeCandidateDecision = "reject" +) + +type PersonMergeCandidateDecisionRequest struct { + CandidateID int64 + PersonID int64 + ExpectedPersonRevision int64 + Decision PersonMergeCandidateDecision + Actor string +} + +// PersonMergeCandidateDecisionResult returns the decided candidate together +// with the person revision committed by the same transaction. +type PersonMergeCandidateDecisionResult struct { + PersonMergeReviewCandidate + + PersonRevision int64 +} + +func (s *Store) MergePersonsContext( + ctx context.Context, request PersonMergeRequest, +) (*PersonMergeResult, error) { + request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey) + request.Actor = strings.TrimSpace(request.Actor) + if err := request.validate(); err != nil { + return nil, err + } + return retryBusyWrite(ctx, s, "merge persons", func() (*PersonMergeResult, error) { + return s.mergePersonsOnce(ctx, request) + }) +} + +func (s *Store) mergePersonsOnce( + ctx context.Context, request PersonMergeRequest, +) (*PersonMergeResult, error) { + requestHash, err := personMergeRequestHash(request) + if err != nil { + return nil, err + } + var result *PersonMergeResult + err = s.withTxContext(ctx, func(tx *loggedTx) error { + if s.personOperationBeforeIdentityLockHook != nil { + s.personOperationBeforeIdentityLockHook() + } + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + replayed, found, err := s.personMergeByIdempotencyKeyTx( + ctx, tx, request.IdempotencyKey, requestHash, + ) + if err != nil { + return err + } + if found { + result = replayed + return nil + } + if err := s.lockMergePeopleTx(ctx, tx, request.SurvivorID, request.AbsorbedID); err != nil { + return err + } + survivor, err := s.getPersonTx(ctx, tx, request.SurvivorID) + if err != nil { + return err + } + absorbed, err := s.getPersonTx(ctx, tx, request.AbsorbedID) + if err != nil { + return err + } + if survivor.Revision != request.ExpectedSurvivorRevision || + absorbed.Revision != request.ExpectedAbsorbedRevision { + return ErrPersonRevisionConflict + } + if err := s.lockPersonMergeVCardEnvelopesTx( + ctx, tx, survivor.ID, absorbed.ID, + ); err != nil { + return err + } + if err := ensurePersonMergeCardDAVStateTx(ctx, tx, survivor.ID, absorbed.ID); err != nil { + return err + } + + snapshot, err := s.capturePersonMergeSnapshotTx(ctx, tx, survivor.ID, absorbed.ID) + if err != nil { + return err + } + if s.personMergeAfterSnapshotHook != nil { + s.personMergeAfterSnapshotHook() + } + compressed, snapshotHash, err := encodePersonMergeSnapshot(snapshot) + if err != nil { + return err + } + mergeID, err := s.insertPersonMergeTx( + ctx, tx, request, requestHash, *survivor, *absorbed, compressed, snapshotHash, + ) + if err != nil { + return err + } + if err := recordPersonMergeParticipantsTx(ctx, tx, mergeID, snapshot.Persons); err != nil { + return err + } + if err := recordPersonMergeSnapshotRowsTx(ctx, tx, mergeID, snapshot.Rows); err != nil { + return err + } + if err := s.moveCorePersonProfileTx( + ctx, tx, mergeID, survivor.ID, absorbed.ID, survivor.VCardUID, + ); err != nil { + return err + } + projectionIDs := []int64{} + if err := s.reconcilePersonRelationshipsTx( + ctx, tx, mergeID, survivor.ID, absorbed.ID, &projectionIDs, + ); err != nil { + return err + } + if err := s.reconcilePersonRelationshipReviewsTx( + ctx, tx, mergeID, survivor.ID, absorbed.ID, &projectionIDs, + ); err != nil { + return err + } + if err := s.bumpPersonVCardProjectionsTx(ctx, tx, projectionIDs...); err != nil { + return err + } + if err := s.reconcilePersonEmploymentsTx( + ctx, tx, mergeID, survivor.ID, absorbed.ID, + ); err != nil { + return err + } + if err := s.reconcilePersonIdentityCandidatesTx( + ctx, tx, mergeID, survivor.ID, absorbed.ID, + ); err != nil { + return err + } + if err := s.reconcilePersonDailyNotesTx( + ctx, tx, mergeID, survivor.ID, absorbed.ID, + ); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `UPDATE person_participants SET person_id = ? WHERE person_id = ?`, + survivor.ID, absorbed.ID); err != nil { + return fmt.Errorf("rebind absorbed person participants: %w", err) + } + if _, err := tx.ExecContext(ctx, + `UPDATE carddav_resources SET person_id = ? WHERE person_id = ?`, + survivor.ID, absorbed.ID); err != nil { + return fmt.Errorf("rebind absorbed CardDAV resources: %w", err) + } + identityRevision, err := s.bumpIdentityRevisionContext(ctx, tx) + if err != nil { + return err + } + accountRevision, err := readAccountIdentityRevision(tx) + if err != nil { + return err + } + if err := s.reconcilePersonActivityStateTx( + ctx, tx, survivor.ID, absorbed.ID, ContactRevisions{ + IdentityRevision: identityRevision, + AccountIdentityRevision: accountRevision, + }, + ); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `UPDATE person_uid_aliases SET surviving_person_id = ? WHERE surviving_person_id = ?`, + survivor.ID, absorbed.ID); err != nil { + return fmt.Errorf("retarget absorbed person UID aliases: %w", err) + } + if _, err := tx.ExecContext(ctx, + `UPDATE person_merges SET current_person_id = ? WHERE current_person_id = ? AND id <> ?`, + survivor.ID, absorbed.ID, mergeID); err != nil { + return fmt.Errorf("retarget prior person merge lineages: %w", err) + } + if _, err := tx.ExecContext(ctx, + `UPDATE person_merge_review_candidates SET survivor_person_id = ? + WHERE survivor_person_id = ?`, survivor.ID, absorbed.ID); err != nil { + return fmt.Errorf("retarget prior person merge candidates: %w", err) + } + if err := s.rebasePriorPersonMergeReferenceBaselinesTx( + ctx, tx, mergeID, absorbed.ID, survivor.ID, + ); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM persons WHERE id = ?`, absorbed.ID); err != nil { + return fmt.Errorf("delete absorbed person: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO person_uid_aliases + (retired_uid, surviving_person_id, reason) VALUES (?, ?, 'merge')`, + absorbed.VCardUID, survivor.ID); err != nil { + return fmt.Errorf("retire absorbed person UID: %w", err) + } + if err := s.bumpPersonRevisionsTx(ctx, tx, survivor.ID); err != nil { + return err + } + if err := s.recordPersonMergePostRowsTx( + ctx, tx, mergeID, absorbed.ID, snapshot, + ); err != nil { + return err + } + merge, err := s.getPersonMergeTx(ctx, tx, mergeID) + if err != nil { + return err + } + result, err = s.personMergeResultTx(ctx, tx, merge) + if err != nil { + return err + } + result.IdentityRevision = identityRevision + encodedResult, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("encode person merge result: %w", err) + } + if _, err := tx.ExecContext(ctx, + `UPDATE person_merges SET result_json = ?, identity_revision = ? WHERE id = ?`, + string(encodedResult), identityRevision, mergeID, + ); err != nil { + return fmt.Errorf("store person merge result: %w", err) + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func personMergeRequestHash(request PersonMergeRequest) (string, error) { + canonical, err := json.Marshal(struct { + SurvivorID int64 `json:"survivor_id"` + AbsorbedID int64 `json:"absorbed_id"` + ExpectedSurvivorRevision int64 `json:"expected_survivor_revision"` + ExpectedAbsorbedRevision int64 `json:"expected_absorbed_revision"` + Actor string `json:"actor"` + }{ + SurvivorID: request.SurvivorID, AbsorbedID: request.AbsorbedID, + ExpectedSurvivorRevision: request.ExpectedSurvivorRevision, + ExpectedAbsorbedRevision: request.ExpectedAbsorbedRevision, + Actor: request.Actor, + }) + if err != nil { + return "", fmt.Errorf("encode person merge request: %w", err) + } + digest := sha256.Sum256(canonical) + return hex.EncodeToString(digest[:]), nil +} + +func (s *Store) personMergeByIdempotencyKeyTx( + ctx context.Context, tx *loggedTx, key, requestHash string, +) (*PersonMergeResult, bool, error) { + var mergeID int64 + var storedHash string + var storedResult sql.NullString + var storedIdentityRevision sql.NullInt64 + err := tx.QueryRowContext(ctx, `SELECT id, request_hash, result_json, identity_revision + FROM person_merges WHERE idempotency_key = ?`, key).Scan( + &mergeID, &storedHash, &storedResult, &storedIdentityRevision, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("load person merge idempotency key: %w", err) + } + if storedHash != requestHash { + return nil, false, ErrPersonMergeIdempotency + } + if !storedResult.Valid || storedResult.String == "" { + return nil, false, errors.New("person merge idempotency result is missing") + } + var result PersonMergeResult + if err := json.Unmarshal([]byte(storedResult.String), &result); err != nil { + return nil, false, fmt.Errorf("decode person merge idempotency result: %w", err) + } + if !storedIdentityRevision.Valid || storedIdentityRevision.Int64 <= 0 || + result.IdentityRevision != storedIdentityRevision.Int64 { + return nil, false, errors.New("person merge idempotency revision is missing or inconsistent") + } + return &result, true, nil +} + +func (s *Store) lockMergePeopleTx( + ctx context.Context, tx *loggedTx, survivorID, absorbedID int64, +) error { + rows, err := tx.QueryContext(ctx, `SELECT id FROM persons + WHERE id IN (?, ?) ORDER BY id`+s.dialect.SelectForUpdate(), survivorID, absorbedID) + if err != nil { + return fmt.Errorf("lock merge people: %w", err) + } + defer func() { _ = rows.Close() }() + count := 0 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return fmt.Errorf("scan locked merge person: %w", err) + } + count++ + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate locked merge people: %w", err) + } + if count != 2 { + return ErrPersonNotFound + } + return nil +} + +func (s *Store) lockPersonMergeVCardEnvelopesTx( + ctx context.Context, tx *loggedTx, survivorID, absorbedID int64, +) error { + rows, err := tx.QueryContext(ctx, `SELECT id FROM vcard_resource_envelopes + WHERE person_id IN (?, ?) ORDER BY id`+s.dialect.SelectForUpdate(), + survivorID, absorbedID) + if err != nil { + return fmt.Errorf("lock person merge vCard envelopes: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return fmt.Errorf("scan locked person merge vCard envelope: %w", err) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate locked person merge vCard envelopes: %w", err) + } + return nil +} + +func ensurePersonMergeCardDAVStateTx( + ctx context.Context, tx *loggedTx, survivorID, absorbedID int64, +) error { + var published bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM carddav_publications WHERE person_id IN (?, ?) + )`, survivorID, absorbedID).Scan(&published); err != nil { + return fmt.Errorf("check person merge CardDAV publications: %w", err) + } + if published { + return ErrPersonCardDAVPublished + } + return nil +} + +func (s *Store) insertPersonMergeTx( + ctx context.Context, + tx *loggedTx, + request PersonMergeRequest, + requestHash string, + survivor, absorbed Person, + snapshot []byte, + snapshotHash string, +) (int64, error) { + var mergeID int64 + err := tx.QueryRowContext(ctx, `INSERT INTO person_merges ( + idempotency_key, request_hash, survivor_person_id_at_merge, + absorbed_person_id, current_person_id, survivor_uid, absorbed_uid, + survivor_revision_before, absorbed_revision_before, survivor_revision_after, + actor, snapshot_version, snapshot_blob, snapshot_sha256 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + request.IdempotencyKey, requestHash, survivor.ID, absorbed.ID, survivor.ID, + survivor.VCardUID, absorbed.VCardUID, survivor.Revision, absorbed.Revision, + survivor.Revision+1, request.Actor, personMergeSnapshotVersion, snapshot, snapshotHash, + ).Scan(&mergeID) + if err != nil { + if s.dialect.IsConflictError(err) { + return 0, ErrPersonMergeIdempotency + } + return 0, fmt.Errorf("insert person merge: %w", err) + } + return mergeID, nil +} + +func recordPersonMergeParticipantsTx( + ctx context.Context, + tx *loggedTx, + mergeID int64, + persons []personMergeSnapshotPerson, +) error { + for i, person := range persons { + origin := personMergeOriginSurvivor + if i == 1 { + origin = personMergeOriginAbsorbed + } + for _, participantID := range person.ParticipantIDs { + if _, err := tx.ExecContext(ctx, `INSERT INTO person_merge_participants + (merge_id, participant_id, origin_side) VALUES (?, ?, ?)`, + mergeID, participantID, origin); err != nil { + return fmt.Errorf("record person merge %d participant lineage: %w", mergeID, err) + } + } + } + return nil +} + +func recordPersonMergeSnapshotRowsTx( + ctx context.Context, tx *loggedTx, mergeID int64, rows []personMergeSnapshotRow, +) error { + for index, row := range rows { + if row.OriginSide == personMergeOriginSurvivor && + (row.ProvenanceKind == personMergeProvenanceDerived || + row.TableName == "activity_event_persons") { + continue + } + var rowID any + if row.RowID > 0 { + rowID = row.RowID + } + action := "moved" + switch row.ProvenanceKind { + case personMergeProvenanceParticipantExact, personMergeProvenanceAbsorbedProfile: + case personMergeProvenanceInboundReference: + action = "repointed" + case personMergeProvenanceDerived: + action = "recomputed" + } + if _, err := tx.ExecContext(ctx, `INSERT INTO person_merge_rows ( + merge_id, table_name, original_row_id, original_row_key, + current_row_id, current_row_key, origin_side, provenance_kind, + participant_id, action, snapshot_path + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + mergeID, row.TableName, rowID, row.RowKey, rowID, row.RowKey, + row.OriginSide, row.ProvenanceKind, row.ParticipantID, action, + fmt.Sprintf("rows/%d", index)); err != nil { + return fmt.Errorf("record person merge %d row table %s: %w", mergeID, row.TableName, err) + } + } + return nil +} + +// recordPersonMergePostRowsTx closes the row journal after reconciliation. +// Absorbed-origin rows always remain in the journal. Survivor-origin rows are +// retained only when the merge changed them, which captures dependency remaps +// without making an exact split rewrite unrelated survivor state. +func (s *Store) recordPersonMergePostRowsTx( + ctx context.Context, + tx *loggedTx, + mergeID, absorbedID int64, + snapshot personMergeSnapshot, +) error { + rowsByPath := make(map[string]personMergeSnapshotRow, len(snapshot.Rows)) + for index, row := range snapshot.Rows { + rowsByPath[fmt.Sprintf("rows/%d", index)] = row + } + journalRows, err := tx.QueryContext(ctx, `SELECT table_name, original_row_id, original_row_key, + current_row_id, current_row_key, origin_side, provenance_kind, action, snapshot_path + FROM person_merge_rows WHERE merge_id = ? + ORDER BY table_name, original_row_key`, mergeID) + if err != nil { + return fmt.Errorf("load person merge post-state journal: %w", err) + } + type pendingRow struct { + table, originalKey, origin, provenance, action, snapshotPath string + originalID sql.NullInt64 + currentID sql.NullInt64 + currentKey sql.NullString + } + pending := []pendingRow{} + for journalRows.Next() { + var row pendingRow + if err := journalRows.Scan( + &row.table, &row.originalID, &row.originalKey, &row.currentID, &row.currentKey, + &row.origin, &row.provenance, &row.action, &row.snapshotPath, + ); err != nil { + _ = journalRows.Close() + return fmt.Errorf("scan person merge post-state journal: %w", err) + } + pending = append(pending, row) + } + if err := journalRows.Err(); err != nil { + _ = journalRows.Close() + return fmt.Errorf("iterate person merge post-state journal: %w", err) + } + if err := journalRows.Close(); err != nil { + return fmt.Errorf("close person merge post-state journal: %w", err) + } + + for _, entry := range pending { + if entry.action == "deleted_snapshot" || entry.action == "recomputed" || + entry.provenance == string(personMergeProvenanceDerived) { + continue + } + original, ok := rowsByPath[entry.snapshotPath] + if !ok { + return fmt.Errorf("%w: missing merge snapshot path %q", + ErrPersonMergeSnapshotCorrupt, entry.snapshotPath) + } + spec, ok := personMergeTableRegistry[entry.table] + if !ok { + return fmt.Errorf("%w: unregistered merge table %q", ErrPersonMergeInvalid, entry.table) + } + where, args, err := personSplitCurrentRowWhere(spec, personSplitJournalRow{ + currentRowID: entry.currentID, currentKey: entry.currentKey, + }) + if err != nil { + return err + } + selectColumns := "*" + switch entry.table { + case "person_merges": + // The immutable audit payload can be very large. Only the key and + // mutable person reference participate in later lineage rebasing. + selectColumns = "id, current_person_id" + case personMergeReviewCandidatesTableName: + selectColumns = "id, survivor_person_id, state, reviewed_at" + } + currentRows, err := s.capturePersonMergeQueryTx(ctx, tx, spec, + `SELECT `+selectColumns+` FROM `+personSplitIdentifier(entry.table)+` WHERE `+where, + args, absorbedID) + if err != nil { + return err + } + if len(currentRows) != 1 { + return fmt.Errorf("%w: current %s row is missing", ErrPersonMergeInvalid, entry.table) + } + postJSON, err := json.Marshal(currentRows[0]) + if err != nil { + return fmt.Errorf("encode %s merge post-state: %w", entry.table, err) + } + originalColumns, err := json.Marshal(original.Columns) + if err != nil { + return fmt.Errorf("encode %s merge original state: %w", entry.table, err) + } + postColumns, err := json.Marshal(currentRows[0].Columns) + if err != nil { + return fmt.Errorf("encode %s merge current state: %w", entry.table, err) + } + if entry.origin == string(personMergeOriginSurvivor) && + bytes.Equal(originalColumns, postColumns) { + if _, err := tx.ExecContext(ctx, `DELETE FROM person_merge_rows + WHERE merge_id = ? AND table_name = ? AND original_row_key = ?`, + mergeID, entry.table, entry.originalKey); err != nil { + return fmt.Errorf("prune unchanged survivor merge row: %w", err) + } + continue + } + if _, err := tx.ExecContext(ctx, `UPDATE person_merge_rows + SET post_merge_row_json = ? + WHERE merge_id = ? AND table_name = ? AND original_row_key = ?`, + string(postJSON), mergeID, entry.table, entry.originalKey); err != nil { + return fmt.Errorf("record %s merge post-state: %w", entry.table, err) + } + if err := syncPersonMergeRowPersonRefsTx( + ctx, tx, mergeID, entry.table, entry.originalKey, currentRows[0], spec, + ); err != nil { + return err + } + } + return nil +} + +func syncPersonMergeRowPersonRefsTx( + ctx context.Context, tx *loggedTx, mergeID int64, table, originalKey string, + row personMergeSnapshotRow, spec personMergeTableSpec, +) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM person_merge_row_person_refs + WHERE merge_id = ? AND table_name = ? AND original_row_key = ?`, + mergeID, table, originalKey); err != nil { + return fmt.Errorf("clear %s merge person references: %w", table, err) + } + for _, reference := range spec.PersonReferences { + if reference.Kind == personMergeReferencePolymorphic && + personSplitSnapshotRowText(row, reference.KindColumn) != reference.KindValue { + continue + } + personID, ok := personMergeSnapshotRowInteger(row, reference.IDColumn) + if !ok { + continue + } + if _, err := tx.ExecContext(ctx, `INSERT INTO person_merge_row_person_refs + (merge_id, table_name, original_row_key, column_name, person_id) + VALUES (?, ?, ?, ?, ?)`, mergeID, table, originalKey, + reference.IDColumn, personID); err != nil { + return fmt.Errorf("record %s merge person reference: %w", table, err) + } + } + return nil +} + +func (s *Store) moveCorePersonProfileTx( + ctx context.Context, + tx *loggedTx, + mergeID, survivorID, absorbedID int64, + survivorUID string, +) error { + for _, table := range []string{ + "person_names", personContactPointsTableName, "person_addresses", "person_dates", "person_media", + } { + if err := s.moveStructuredPersonRowsTx(ctx, tx, mergeID, table, survivorID, absorbedID); err != nil { + return err + } + } + if err := s.movePersonCategoriesTx(ctx, tx, mergeID, survivorID, absorbedID); err != nil { + return err + } + if err := s.movePersonAttributesTx(ctx, tx, mergeID, survivorID, absorbedID); err != nil { + return err + } + if err := s.reconcilePersonTrackingTx(ctx, tx, mergeID, survivorID, absorbedID); err != nil { + return err + } + projectionPersonIDs, err := personMergeRowIDsTx(ctx, tx, `SELECT person_id + FROM person_attribute_values + WHERE value_record_type = 'person' AND value_record_id = ? + AND person_id NOT IN (?, ?) + GROUP BY person_id + ORDER BY person_id`, absorbedID, survivorID, absorbedID) + if err != nil { + return fmt.Errorf("load inbound person attribute owners: %w", err) + } + employedPersonIDs, err := personMergeRowIDsTx(ctx, tx, `SELECT employment.person_id + FROM employments employment + WHERE employment.person_id NOT IN (?, ?) + AND EXISTS ( + SELECT 1 FROM organization_attribute_values value + WHERE value.organization_id = employment.organization_id + AND value.value_record_type = 'person' AND value.value_record_id = ? + ) + GROUP BY employment.person_id + ORDER BY employment.person_id`, absorbedID, survivorID, absorbedID) + if err != nil { + return fmt.Errorf("load inbound organization attribute projections: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE person_attribute_values + SET value_record_id = ? WHERE value_record_type = 'person' AND value_record_id = ?`, + survivorID, absorbedID); err != nil { + return fmt.Errorf("repoint person attribute references: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE organization_attribute_values + SET value_record_id = ? WHERE value_record_type = 'person' AND value_record_id = ?`, + survivorID, absorbedID); err != nil { + return fmt.Errorf("repoint organization attribute references: %w", err) + } + if err := s.bumpPersonVCardProjectionsTx( + ctx, tx, append(projectionPersonIDs, employedPersonIDs...)..., + ); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE vcard_resource_envelopes + SET person_id = ?, canonical_person_uid = ?, revision = revision + 1, + updated_at = `+s.dialect.Now()+` + WHERE person_id = ?`, survivorID, survivorUID, absorbedID); err != nil { + return fmt.Errorf("move absorbed vCard resources: %w", err) + } + return nil +} + +func (s *Store) reconcilePersonTrackingTx( + ctx context.Context, tx *loggedTx, mergeID, survivorID, absorbedID int64, +) error { + var survivorTracked, absorbedTracked bool + if err := tx.QueryRowContext(ctx, `SELECT + EXISTS (SELECT 1 FROM person_tracking WHERE person_id = ?), + EXISTS (SELECT 1 FROM person_tracking WHERE person_id = ?)`, + survivorID, absorbedID, + ).Scan(&survivorTracked, &absorbedTracked); err != nil { + return fmt.Errorf("inspect person tracking before merge: %w", err) + } + if !absorbedTracked { + return nil + } + if survivorTracked { + if _, err := tx.ExecContext(ctx, + `DELETE FROM person_tracking WHERE person_id = ?`, absorbedID); err != nil { + return fmt.Errorf("deduplicate absorbed person tracking: %w", err) + } + return s.setPersonMergeRowDispositionTx( + ctx, tx, mergeID, "person_tracking", absorbedID, personMergeActionDeduplicated, &survivorID, + ) + } + if _, err := tx.ExecContext(ctx, `UPDATE person_tracking + SET person_id = ? WHERE person_id = ?`, survivorID, absorbedID); err != nil { + return fmt.Errorf("move absorbed person tracking: %w", err) + } + return s.setPersonMergeRowDispositionTx( + ctx, tx, mergeID, "person_tracking", absorbedID, "moved", &survivorID, + ) +} + +func (s *Store) moveStructuredPersonRowsTx( + ctx context.Context, tx *loggedTx, mergeID int64, table string, survivorID, absorbedID int64, +) error { + if _, ok := map[string]struct{}{ + "person_names": {}, personContactPointsTableName: {}, "person_addresses": {}, + "person_dates": {}, "person_media": {}, + }[table]; !ok { + return fmt.Errorf("%w: unregistered structured table %q", ErrPersonMergeInvalid, table) + } + duplicatePredicate := fmt.Sprintf(`person_id = ? AND superseded_at IS NULL + AND source_ref IS NOT NULL AND vcard_prop_id IS NOT NULL + AND EXISTS (SELECT 1 FROM %s survivor + WHERE survivor.person_id = ? + AND survivor.superseded_at IS NULL + AND survivor.source = %s.source + AND survivor.source_ref IS NOT DISTINCT FROM %s.source_ref + AND COALESCE(survivor.source_resource_uid, '') = + COALESCE(%s.source_resource_uid, '') + AND survivor.vcard_property IS NOT DISTINCT FROM %s.vcard_property + AND survivor.vcard_prop_id IS NOT DISTINCT FROM %s.vcard_prop_id)`, + table, table, table, table, table, table) + duplicateIDs, err := personMergeRowIDsTx(ctx, tx, + fmt.Sprintf(`SELECT id FROM %s WHERE %s ORDER BY id`, table, duplicatePredicate), + absorbedID, survivorID) + if err != nil { + return fmt.Errorf("load duplicate %s rows: %w", table, err) + } + query := fmt.Sprintf(`UPDATE %s SET + active_until = COALESCE(active_until, + CASE WHEN %s < active_from THEN active_from ELSE %s END), + superseded_at = COALESCE(superseded_at, %s) + WHERE %s`, table, s.dialect.Now(), s.dialect.Now(), s.dialect.Now(), duplicatePredicate) + if _, err := tx.ExecContext(ctx, query, absorbedID, survivorID); err != nil { + return fmt.Errorf("supersede duplicate %s rows: %w", table, err) + } + if err := markPersonMergeRowsDeduplicatedTx(ctx, tx, mergeID, table, duplicateIDs); err != nil { + return err + } + var maxOrdinal int64 + err = tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT COALESCE(MAX(ordinal), -1) + FROM %s WHERE person_id = ? AND active_until IS NULL AND superseded_at IS NULL`, table), + survivorID).Scan(&maxOrdinal) + if err != nil { + return fmt.Errorf("read survivor %s ordinal: %w", table, err) + } + if maxOrdinal >= 0 { + if _, err := tx.ExecContext(ctx, fmt.Sprintf(`UPDATE %s SET ordinal = ordinal + ? + WHERE person_id = ? AND active_until IS NULL AND superseded_at IS NULL`, table), + maxOrdinal+1, absorbedID); err != nil { + return fmt.Errorf("reordinal absorbed %s rows: %w", table, err) + } + } + if _, err := tx.ExecContext(ctx, + fmt.Sprintf(`UPDATE %s SET person_id = ? WHERE person_id = ?`, table), + survivorID, absorbedID); err != nil { + return fmt.Errorf("move absorbed %s rows: %w", table, err) + } + return nil +} + +func (s *Store) movePersonCategoriesTx( + ctx context.Context, tx *loggedTx, mergeID, survivorID, absorbedID int64, +) error { + duplicateIDs, err := personMergeRowIDsTx(ctx, tx, `SELECT id FROM person_categories + WHERE person_id = ? AND active_until IS NULL AND superseded_at IS NULL + AND EXISTS (SELECT 1 FROM person_categories survivor + WHERE survivor.person_id = ? + AND survivor.normalized_value = person_categories.normalized_value + AND survivor.active_until IS NULL AND survivor.superseded_at IS NULL) + ORDER BY id`, absorbedID, survivorID) + if err != nil { + return fmt.Errorf("load duplicate absorbed categories: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE person_categories SET + active_until = COALESCE(active_until, + CASE WHEN `+s.dialect.Now()+` < active_from THEN active_from ELSE `+s.dialect.Now()+` END), + superseded_at = COALESCE(superseded_at, `+s.dialect.Now()+`) + WHERE person_id = ? AND active_until IS NULL AND superseded_at IS NULL + AND EXISTS (SELECT 1 FROM person_categories survivor + WHERE survivor.person_id = ? + AND survivor.normalized_value = person_categories.normalized_value + AND survivor.active_until IS NULL AND survivor.superseded_at IS NULL)`, + absorbedID, survivorID); err != nil { + return fmt.Errorf("supersede duplicate absorbed categories: %w", err) + } + if err := markPersonMergeRowsDeduplicatedTx( + ctx, tx, mergeID, "person_categories", duplicateIDs, + ); err != nil { + return err + } + var maxOrdinal int64 + if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(ordinal), -1) + FROM person_categories + WHERE person_id = ? AND active_until IS NULL AND superseded_at IS NULL`, + survivorID).Scan(&maxOrdinal); err != nil { + return fmt.Errorf("read survivor category ordinal: %w", err) + } + if maxOrdinal >= 0 { + if _, err := tx.ExecContext(ctx, `UPDATE person_categories SET ordinal = ordinal + ? + WHERE person_id = ? AND active_until IS NULL AND superseded_at IS NULL`, + maxOrdinal+1, absorbedID); err != nil { + return fmt.Errorf("reordinal absorbed categories: %w", err) + } + } + if _, err := tx.ExecContext(ctx, + `UPDATE person_categories SET person_id = ? WHERE person_id = ?`, + survivorID, absorbedID); err != nil { + return fmt.Errorf("move absorbed categories: %w", err) + } + return nil +} + +func (s *Store) movePersonAttributesTx( + ctx context.Context, tx *loggedTx, mergeID, survivorID, absorbedID int64, +) error { + singleValueLockClause := s.dialect.SelectForUpdate() + if singleValueLockClause != "" { + singleValueLockClause += " OF a" + } + rows, err := tx.QueryContext(ctx, `SELECT + a.id, a.definition_id, survivor.id, + CASE WHEN survivor.id IS NULL THEN FALSE ELSE + a.value_text IS NOT DISTINCT FROM survivor.value_text AND + a.value_integer IS NOT DISTINCT FROM survivor.value_integer AND + a.value_real IS NOT DISTINCT FROM survivor.value_real AND + a.value_boolean IS NOT DISTINCT FROM survivor.value_boolean AND + a.value_date IS NOT DISTINCT FROM survivor.value_date AND + a.value_timestamp IS NOT DISTINCT FROM survivor.value_timestamp AND + a.value_record_type IS NOT DISTINCT FROM survivor.value_record_type AND + a.value_record_id IS NOT DISTINCT FROM survivor.value_record_id + END, + a.value_json, survivor.value_json + FROM person_attribute_values a + JOIN attribute_definitions definition ON definition.id = a.definition_id + LEFT JOIN person_attribute_values survivor + ON survivor.person_id = ? AND survivor.definition_id = a.definition_id + AND survivor.ordinal = a.ordinal + AND survivor.active_until IS NULL AND survivor.superseded_at IS NULL + WHERE a.person_id = ? AND definition.cardinality = 'single' + AND a.active_until IS NULL AND a.superseded_at IS NULL + ORDER BY a.definition_id, a.id`+singleValueLockClause, survivorID, absorbedID) + if err != nil { + return fmt.Errorf("load absorbed single attributes: %w", err) + } + type conflict struct { + absorbedValueID int64 + definitionID int64 + survivorValueID sql.NullInt64 + equal bool + absorbedJSON sql.NullString + survivorJSON sql.NullString + } + conflicts := []conflict{} + for rows.Next() { + var value conflict + if err := rows.Scan( + &value.absorbedValueID, &value.definitionID, + &value.survivorValueID, &value.equal, + &value.absorbedJSON, &value.survivorJSON, + ); err != nil { + _ = rows.Close() + return fmt.Errorf("scan absorbed single attribute: %w", err) + } + jsonEqual, err := personMergeJSONValuesEqual(value.absorbedJSON, value.survivorJSON) + if err != nil { + _ = rows.Close() + return fmt.Errorf("compare single attribute JSON: %w", err) + } + value.equal = value.equal && jsonEqual + conflicts = append(conflicts, value) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate absorbed single attributes: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close absorbed single attributes: %w", err) + } + equalValueIDs := []int64{} + for _, value := range conflicts { + if !value.survivorValueID.Valid { + continue + } + if _, err := tx.ExecContext(ctx, `UPDATE person_attribute_values SET + active_until = COALESCE(active_until, + CASE WHEN `+s.dialect.Now()+` < active_from THEN active_from ELSE `+s.dialect.Now()+` END), + superseded_at = COALESCE(superseded_at, `+s.dialect.Now()+`) + WHERE id = ?`, value.absorbedValueID); err != nil { + return fmt.Errorf("retain absorbed single attribute history: %w", err) + } + if value.equal { + equalValueIDs = append(equalValueIDs, value.absorbedValueID) + continue + } + if _, err := tx.ExecContext(ctx, `INSERT INTO person_merge_review_candidates ( + merge_id, survivor_person_id, definition_id, + survivor_value_id, absorbed_value_id + ) VALUES (?, ?, ?, ?, ?)`, mergeID, survivorID, value.definitionID, + value.survivorValueID.Int64, value.absorbedValueID); err != nil { + return fmt.Errorf("record person merge attribute candidate: %w", err) + } + } + if err := markPersonMergeRowsDeduplicatedTx( + ctx, tx, mergeID, personAttributeValuesTableName, equalValueIDs, + ); err != nil { + return err + } + + multiValueLockClause := s.dialect.SelectForUpdate() + if multiValueLockClause != "" { + multiValueLockClause += " OF absorbed, survivor" + } + duplicateRows, err := tx.QueryContext(ctx, `SELECT absorbed.id, survivor.id, + absorbed.value_json, survivor.value_json + FROM person_attribute_values absorbed + JOIN attribute_definitions definition + ON definition.id = absorbed.definition_id AND definition.cardinality = 'multi' + JOIN person_attribute_values survivor + ON survivor.person_id = ? AND survivor.definition_id = absorbed.definition_id + AND survivor.active_until IS NULL AND survivor.superseded_at IS NULL + AND absorbed.value_text IS NOT DISTINCT FROM survivor.value_text + AND absorbed.value_integer IS NOT DISTINCT FROM survivor.value_integer + AND absorbed.value_real IS NOT DISTINCT FROM survivor.value_real + AND absorbed.value_boolean IS NOT DISTINCT FROM survivor.value_boolean + AND absorbed.value_date IS NOT DISTINCT FROM survivor.value_date + AND absorbed.value_timestamp IS NOT DISTINCT FROM survivor.value_timestamp + AND absorbed.value_record_type IS NOT DISTINCT FROM survivor.value_record_type + AND absorbed.value_record_id IS NOT DISTINCT FROM survivor.value_record_id + WHERE absorbed.person_id = ? + AND absorbed.active_until IS NULL AND absorbed.superseded_at IS NULL + ORDER BY absorbed.id, survivor.id`+multiValueLockClause, survivorID, absorbedID) + if err != nil { + return fmt.Errorf("load duplicate absorbed multi attributes: %w", err) + } + duplicateValueIDs := []int64{} + seenDuplicate := map[int64]struct{}{} + for duplicateRows.Next() { + var absorbedValueID, survivorValueID int64 + var absorbedJSON, survivorJSON sql.NullString + if err := duplicateRows.Scan( + &absorbedValueID, &survivorValueID, &absorbedJSON, &survivorJSON, + ); err != nil { + _ = duplicateRows.Close() + return fmt.Errorf("scan duplicate absorbed multi attribute: %w", err) + } + if _, seen := seenDuplicate[absorbedValueID]; seen { + continue + } + equal, err := personMergeJSONValuesEqual(absorbedJSON, survivorJSON) + if err != nil { + _ = duplicateRows.Close() + return fmt.Errorf("compare multi attribute JSON: %w", err) + } + if equal { + seenDuplicate[absorbedValueID] = struct{}{} + duplicateValueIDs = append(duplicateValueIDs, absorbedValueID) + } + } + if err := duplicateRows.Err(); err != nil { + _ = duplicateRows.Close() + return fmt.Errorf("iterate duplicate absorbed multi attributes: %w", err) + } + if err := duplicateRows.Close(); err != nil { + return fmt.Errorf("close duplicate absorbed multi attributes: %w", err) + } + for _, valueID := range duplicateValueIDs { + if _, err := tx.ExecContext(ctx, `UPDATE person_attribute_values SET + active_until = COALESCE(active_until, + CASE WHEN `+s.dialect.Now()+` < active_from THEN active_from ELSE `+s.dialect.Now()+` END), + superseded_at = COALESCE(superseded_at, `+s.dialect.Now()+`) + WHERE id = ?`, valueID); err != nil { + return fmt.Errorf("retain duplicate absorbed multi attribute history: %w", err) + } + } + if err := markPersonMergeRowsDeduplicatedTx( + ctx, tx, mergeID, personAttributeValuesTableName, + duplicateValueIDs, + ); err != nil { + return err + } + + multiRows, err := tx.QueryContext(ctx, `SELECT a.definition_id, + COALESCE((SELECT MAX(survivor.ordinal) FROM person_attribute_values survivor + WHERE survivor.person_id = ? AND survivor.definition_id = a.definition_id + AND survivor.active_until IS NULL AND survivor.superseded_at IS NULL), -1) + FROM person_attribute_values a + WHERE a.person_id = ? + AND EXISTS (SELECT 1 FROM attribute_definitions definition + WHERE definition.id = a.definition_id AND definition.cardinality = 'multi') + AND a.active_until IS NULL AND a.superseded_at IS NULL + GROUP BY a.definition_id + ORDER BY a.definition_id`, survivorID, absorbedID) + if err != nil { + return fmt.Errorf("load absorbed multi attribute ordinals: %w", err) + } + type offset struct{ definitionID, maxOrdinal int64 } + offsets := []offset{} + for multiRows.Next() { + var value offset + if err := multiRows.Scan(&value.definitionID, &value.maxOrdinal); err != nil { + _ = multiRows.Close() + return fmt.Errorf("scan absorbed multi attribute ordinal: %w", err) + } + offsets = append(offsets, value) + } + if err := multiRows.Err(); err != nil { + _ = multiRows.Close() + return fmt.Errorf("iterate absorbed multi attribute ordinals: %w", err) + } + if err := multiRows.Close(); err != nil { + return fmt.Errorf("close absorbed multi attribute ordinals: %w", err) + } + for _, value := range offsets { + if value.maxOrdinal < 0 { + continue + } + if _, err := tx.ExecContext(ctx, `UPDATE person_attribute_values + SET ordinal = ordinal + ? + WHERE person_id = ? AND definition_id = ? + AND active_until IS NULL AND superseded_at IS NULL`, + value.maxOrdinal+1, absorbedID, value.definitionID); err != nil { + return fmt.Errorf("reordinal absorbed multi attributes: %w", err) + } + } + if _, err := tx.ExecContext(ctx, + `UPDATE person_attribute_values SET person_id = ? WHERE person_id = ?`, + survivorID, absorbedID); err != nil { + return fmt.Errorf("move absorbed person attributes: %w", err) + } + return nil +} + +func personMergeJSONValuesEqual(left, right sql.NullString) (bool, error) { + if left.Valid != right.Valid { + return false, nil + } + if !left.Valid { + return true, nil + } + leftValue, err := normalizePersonMergeSnapshotValue(left.String, "JSON") + if err != nil { + return false, err + } + rightValue, err := normalizePersonMergeSnapshotValue(right.String, "JSON") + if err != nil { + return false, err + } + return leftValue.Text != nil && rightValue.Text != nil && *leftValue.Text == *rightValue.Text, nil +} + +func personMergeRowIDsTx( + ctx context.Context, tx *loggedTx, query string, args ...any, +) ([]int64, error) { + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + ids := []int64{} + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return ids, nil +} + +func markPersonMergeRowsDeduplicatedTx( + ctx context.Context, + tx *loggedTx, + mergeID int64, + table string, + rowIDs []int64, +) error { + if len(rowIDs) == 0 { + return nil + } + args := []any{personMergeActionDeduplicated, mergeID, table} + for _, rowID := range rowIDs { + args = append(args, rowID) + } + _, err := tx.ExecContext(ctx, `UPDATE person_merge_rows SET action = ? + WHERE merge_id = ? AND table_name = ? AND original_row_id IN (`+ + personMergeSnapshotPlaceholders(len(rowIDs))+`)`, args...) + if err != nil { + return fmt.Errorf("mark %s merge rows deduplicated: %w", table, err) + } + return nil +} + +func (s *Store) rebasePriorPersonMergeReferenceBaselinesTx( + ctx context.Context, + tx *loggedTx, + mergeID, absorbedID, survivorID int64, +) error { + rows, err := tx.QueryContext(ctx, `SELECT merge_id, table_name, original_row_key, + current_row_id, current_row_key, post_merge_row_json + FROM person_merge_rows + WHERE merge_id <> ? AND split_id IS NULL AND post_merge_row_json IS NOT NULL + AND EXISTS (SELECT 1 FROM person_merge_row_person_refs reference + WHERE reference.merge_id = person_merge_rows.merge_id + AND reference.table_name = person_merge_rows.table_name + AND reference.original_row_key = person_merge_rows.original_row_key + AND reference.person_id = ?) + ORDER BY merge_id, table_name, original_row_key`, mergeID, absorbedID) + if err != nil { + return fmt.Errorf("load prior person-reference merge journals: %w", err) + } + type priorRow struct { + mergeID int64 + table string + originalKey string + currentRowID sql.NullInt64 + currentRowKey sql.NullString + postMergeJSON sql.NullString + } + prior := []priorRow{} + for rows.Next() { + var row priorRow + if err := rows.Scan(&row.mergeID, &row.table, &row.originalKey, + &row.currentRowID, &row.currentRowKey, &row.postMergeJSON); err != nil { + _ = rows.Close() + return fmt.Errorf("scan prior person-reference merge journal: %w", err) + } + prior = append(prior, row) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate prior person-reference merge journals: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close prior person-reference merge journals: %w", err) + } + + for _, row := range prior { + spec, ok := personMergeTableRegistry[row.table] + if !ok { + return fmt.Errorf("%w: unregistered merge table %q", ErrPersonMergeInvalid, row.table) + } + if !row.currentRowID.Valid && !row.currentRowKey.Valid { + continue + } + where, args, err := personSplitCurrentRowWhere(spec, personSplitJournalRow{ + currentRowID: row.currentRowID, currentKey: row.currentRowKey, + }) + if err != nil { + return err + } + current, err := s.capturePersonMergeQueryTx(ctx, tx, spec, + `SELECT * FROM `+personSplitIdentifier(row.table)+` WHERE `+where, args, absorbedID) + if err != nil { + return err + } + if len(current) == 0 { + continue + } + if len(current) != 1 { + return fmt.Errorf("%w: current %s row is ambiguous", ErrPersonMergeInvalid, row.table) + } + rebased, changed, err := rebasePersonMergePostRowReferences( + row.postMergeJSON, current[0], spec, map[int64]int64{absorbedID: survivorID}, + ) + if err != nil { + return err + } + if !changed { + continue + } + if _, err := tx.ExecContext(ctx, `UPDATE person_merge_rows + SET post_merge_row_json = ? + WHERE merge_id = ? AND table_name = ? AND original_row_key = ? + AND split_id IS NULL`, rebased, row.mergeID, row.table, row.originalKey); err != nil { + return fmt.Errorf("rebase prior %s person-reference journal: %w", row.table, err) + } + if err := syncPersonMergeRowPersonRefsTx( + ctx, tx, row.mergeID, row.table, row.originalKey, current[0], spec, + ); err != nil { + return err + } + } + return nil +} + +func (s *Store) setPersonMergeRowDispositionTx( + ctx context.Context, + tx *loggedTx, + mergeID int64, + table string, + originalRowID int64, + action string, + currentRowID *int64, +) error { + var currentRowKey any + if currentRowID != nil { + spec, ok := personMergeTableRegistry[table] + if !ok || len(spec.keyColumns()) != 1 { + return fmt.Errorf("%w: invalid %s integer row key", ErrPersonMergeInvalid, table) + } + keyColumn := spec.keyColumns()[0] + key, err := canonicalPersonMergeSnapshotRowKey([]personMergeSnapshotColumn{{ + Name: keyColumn, + Value: personMergeSnapshotValue{ + Kind: personMergeSnapshotInteger, Integer: currentRowID, + }, + }}, []string{keyColumn}) + if err != nil { + return fmt.Errorf("encode %s current merge row key: %w", table, err) + } + currentRowKey = key + } + if _, err := tx.ExecContext(ctx, `UPDATE person_merge_rows + SET action = CASE + WHEN ? IN ('deduplicated', 'deleted_snapshot') THEN ? + ELSE action + END, + current_row_id = ?, current_row_key = ? + WHERE merge_id <> ? AND table_name = ? AND current_row_id = ? + AND split_id IS NULL`, + action, action, currentRowID, currentRowKey, mergeID, table, originalRowID); err != nil { + return fmt.Errorf("rebase prior %s merge row journal: %w", table, err) + } + result, err := tx.ExecContext(ctx, `UPDATE person_merge_rows + SET action = ?, current_row_id = ?, current_row_key = ? + WHERE merge_id = ? AND table_name = ? AND original_row_id = ?`, + action, currentRowID, currentRowKey, mergeID, table, originalRowID) + if err != nil { + return fmt.Errorf("set %s merge row disposition: %w", table, err) + } + if affected, affectedErr := result.RowsAffected(); affectedErr != nil { + return fmt.Errorf("count %s merge row disposition: %w", table, affectedErr) + } else if affected != 1 { + return fmt.Errorf("%w: missing %s merge row journal", ErrPersonMergeInvalid, table) + } + return nil +} + +func (s *Store) setPersonMergeRowKeyDispositionTx( + ctx context.Context, + tx *loggedTx, + mergeID int64, + table, originalRowKey, action string, + currentRowKey *string, +) error { + if _, err := tx.ExecContext(ctx, `UPDATE person_merge_rows + SET action = CASE + WHEN ? IN ('deduplicated', 'deleted_snapshot') THEN ? + ELSE action + END, + current_row_id = NULL, current_row_key = ? + WHERE merge_id <> ? AND table_name = ? AND current_row_key = ? + AND split_id IS NULL`, + action, action, currentRowKey, mergeID, table, originalRowKey); err != nil { + return fmt.Errorf("rebase prior %s merge row key journal: %w", table, err) + } + result, err := tx.ExecContext(ctx, `UPDATE person_merge_rows + SET action = ?, current_row_id = NULL, current_row_key = ? + WHERE merge_id = ? AND table_name = ? AND original_row_key = ?`, + action, currentRowKey, mergeID, table, originalRowKey) + if err != nil { + return fmt.Errorf("set %s merge row key disposition: %w", table, err) + } + if affected, affectedErr := result.RowsAffected(); affectedErr != nil { + return fmt.Errorf("count %s merge row key disposition: %w", table, affectedErr) + } else if affected != 1 { + return fmt.Errorf("%w: missing %s merge row key journal", ErrPersonMergeInvalid, table) + } + return nil +} + +func personMergeIntegerRowKey(names []string, values ...int64) (string, error) { + if len(names) != len(values) || len(names) == 0 { + return "", fmt.Errorf("%w: invalid integer row key", ErrPersonMergeInvalid) + } + columns := make([]personMergeSnapshotColumn, len(names)) + for index := range names { + value := values[index] + columns[index] = personMergeSnapshotColumn{ + Name: names[index], + Value: personMergeSnapshotValue{ + Kind: personMergeSnapshotInteger, Integer: &value, + }, + } + } + return canonicalPersonMergeSnapshotRowKey(columns, names) +} + +func (s *Store) reconcilePersonRelationshipsTx( + ctx context.Context, tx *loggedTx, mergeID, survivorID, absorbedID int64, + projectionIDs *[]int64, +) error { + reviewOwnerIDs, err := personMergeRowIDsTx(ctx, tx, `SELECT review.person_id + FROM person_relationship_reviews review + WHERE EXISTS (SELECT 1 FROM person_relationships relationship + WHERE relationship.id = review.accepted_relationship_id + AND (relationship.source_person_id = ? OR relationship.target_person_id = ?)) + AND review.person_id NOT IN (?, ?) + GROUP BY review.person_id + ORDER BY review.person_id`, absorbedID, absorbedID, survivorID, absorbedID) + if err != nil { + return fmt.Errorf("load relationship review projection owners: %w", err) + } + *projectionIDs = append(*projectionIDs, reviewOwnerIDs...) + rows, err := tx.QueryContext(ctx, `SELECT + r.id, r.source_person_id, r.target_person_id, r.relationship_type_id, + r.end_year, t.is_symmetric + FROM person_relationships r + JOIN relationship_types t ON t.id = r.relationship_type_id + WHERE r.source_person_id = ? OR r.target_person_id = ? + ORDER BY r.id`, absorbedID, absorbedID) + if err != nil { + return fmt.Errorf("load absorbed person relationships: %w", err) + } + type relationshipMove struct { + id, sourceID, targetID, typeID int64 + endYear sql.NullInt64 + symmetric bool + } + moves := []relationshipMove{} + for rows.Next() { + var move relationshipMove + if err := rows.Scan( + &move.id, &move.sourceID, &move.targetID, &move.typeID, + &move.endYear, &move.symmetric, + ); err != nil { + _ = rows.Close() + return fmt.Errorf("scan absorbed person relationship: %w", err) + } + moves = append(moves, move) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate absorbed person relationships: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close absorbed person relationships: %w", err) + } + + for _, move := range moves { + sourceID, targetID := move.sourceID, move.targetID + if sourceID == absorbedID { + sourceID = survivorID + } + if targetID == absorbedID { + targetID = survivorID + } + if move.symmetric && sourceID > targetID { + sourceID, targetID = targetID, sourceID + } + for _, personID := range []int64{move.sourceID, move.targetID, sourceID, targetID} { + if personID != survivorID && personID != absorbedID { + *projectionIDs = append(*projectionIDs, personID) + } + } + + if sourceID == targetID { + if _, err := tx.ExecContext(ctx, `UPDATE person_relationship_reviews + SET accepted_relationship_id = NULL, status = 'rejected', + reviewed_by = COALESCE(reviewed_by, 'system'), + reviewed_at = COALESCE(reviewed_at, `+s.dialect.Now()+`), + updated_at = `+s.dialect.Now()+` + WHERE accepted_relationship_id = ? AND status = 'accepted'`, move.id); err != nil { + return fmt.Errorf("reject review for relationship collapsed to self-edge: %w", err) + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM person_relationships WHERE id = ?`, move.id, + ); err != nil { + return fmt.Errorf("delete relationship collapsed to self-edge: %w", err) + } + if err := s.setPersonMergeRowDispositionTx( + ctx, tx, mergeID, personRelationshipsTableName, move.id, + "deleted_snapshot", nil, + ); err != nil { + return err + } + continue + } + + var duplicateID int64 + if !move.endYear.Valid { + err = tx.QueryRowContext(ctx, `SELECT id FROM person_relationships + WHERE id <> ? AND source_person_id = ? AND target_person_id = ? + AND relationship_type_id = ? AND end_year IS NULL + ORDER BY id LIMIT 1`, move.id, sourceID, targetID, move.typeID).Scan(&duplicateID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("find duplicate person relationship: %w", err) + } + } + if duplicateID > 0 { + if _, err := tx.ExecContext(ctx, `UPDATE person_relationship_reviews + SET accepted_relationship_id = ?, updated_at = `+s.dialect.Now()+` + WHERE accepted_relationship_id = ?`, duplicateID, move.id); err != nil { + return fmt.Errorf("repoint deduplicated relationship reviews: %w", err) + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM person_relationships WHERE id = ?`, move.id, + ); err != nil { + return fmt.Errorf("delete duplicate person relationship: %w", err) + } + if err := s.setPersonMergeRowDispositionTx( + ctx, tx, mergeID, personRelationshipsTableName, move.id, + personMergeActionDeduplicated, &duplicateID, + ); err != nil { + return err + } + continue + } + + if _, err := tx.ExecContext(ctx, `UPDATE person_relationships + SET source_person_id = ?, target_person_id = ?, revision = revision + 1, + updated_at = `+s.dialect.Now()+` + WHERE id = ?`, sourceID, targetID, move.id); err != nil { + return fmt.Errorf("repoint person relationship: %w", err) + } + } + return nil +} + +func (s *Store) reconcilePersonRelationshipReviewsTx( + ctx context.Context, tx *loggedTx, mergeID, survivorID, absorbedID int64, + projectionIDs *[]int64, +) error { + rows, err := tx.QueryContext(ctx, `SELECT id, person_id, matched_person_id + FROM person_relationship_reviews + WHERE person_id = ? OR matched_person_id = ? + ORDER BY CASE WHEN person_id = ? THEN 1 ELSE 0 END, id`, + absorbedID, absorbedID, absorbedID) + if err != nil { + return fmt.Errorf("load absorbed relationship reviews: %w", err) + } + type reviewMove struct { + id, personID int64 + matchedID sql.NullInt64 + } + moves := []reviewMove{} + for rows.Next() { + var move reviewMove + if err := rows.Scan(&move.id, &move.personID, &move.matchedID); err != nil { + _ = rows.Close() + return fmt.Errorf("scan absorbed relationship review: %w", err) + } + moves = append(moves, move) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate absorbed relationship reviews: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close absorbed relationship reviews: %w", err) + } + + for _, move := range moves { + if move.personID != survivorID && move.personID != absorbedID { + *projectionIDs = append(*projectionIDs, move.personID) + } + personID := move.personID + if personID == absorbedID { + personID = survivorID + } + matchedID := move.matchedID + if matchedID.Valid && matchedID.Int64 == absorbedID { + matchedID.Int64 = survivorID + } + if matchedID.Valid && matchedID.Int64 == personID { + matchedID.Valid = false + } + + var duplicateID int64 + err := tx.QueryRowContext(ctx, `SELECT existing.id + FROM person_relationship_reviews candidate + JOIN person_relationship_reviews existing + ON existing.id <> candidate.id + AND existing.person_id = ? + AND existing.raw_related_type = candidate.raw_related_type + AND existing.raw_related_value = candidate.raw_related_value + AND existing.source = candidate.source + AND COALESCE(existing.source_ref, '') = COALESCE(candidate.source_ref, '') + AND COALESCE(existing.source_resource_uid, '') = + COALESCE(candidate.source_resource_uid, '') + AND COALESCE(existing.vcard_property, '') = COALESCE(candidate.vcard_property, '') + AND COALESCE(existing.vcard_group, '') = COALESCE(candidate.vcard_group, '') + AND COALESCE(existing.vcard_prop_id, '') = COALESCE(candidate.vcard_prop_id, '') + AND COALESCE(existing.vcard_pid, '') = COALESCE(candidate.vcard_pid, '') + AND COALESCE(existing.vcard_altid, '') = COALESCE(candidate.vcard_altid, '') + WHERE candidate.id = ? + ORDER BY existing.id LIMIT 1`, personID, move.id).Scan(&duplicateID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("find duplicate relationship review: %w", err) + } + if duplicateID > 0 { + if _, err := tx.ExecContext(ctx, + `DELETE FROM person_relationship_reviews WHERE id = ?`, move.id, + ); err != nil { + return fmt.Errorf("delete duplicate relationship review: %w", err) + } + if err := s.setPersonMergeRowDispositionTx( + ctx, tx, mergeID, "person_relationship_reviews", move.id, + personMergeActionDeduplicated, &duplicateID, + ); err != nil { + return err + } + continue + } + if _, err := tx.ExecContext(ctx, `UPDATE person_relationship_reviews + SET person_id = ?, matched_person_id = ?, updated_at = `+s.dialect.Now()+` + WHERE id = ?`, personID, matchedID, move.id); err != nil { + return fmt.Errorf("repoint relationship review: %w", err) + } + } + return nil +} + +func (s *Store) reconcilePersonEmploymentsTx( + ctx context.Context, tx *loggedTx, mergeID, survivorID, absorbedID int64, +) error { + var survivorHasPrimary bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM employments WHERE person_id = ? + AND `+s.dialect.BoolTrueExpr("is_current")+` + AND `+s.dialect.BoolTrueExpr("is_primary")+` + )`, survivorID).Scan(&survivorHasPrimary); err != nil { + return fmt.Errorf("load survivor primary employment: %w", err) + } + rows, err := tx.QueryContext(ctx, `SELECT + id, organization_id, title_normalized, is_current, is_primary + FROM employments WHERE person_id = ? ORDER BY id`, absorbedID) + if err != nil { + return fmt.Errorf("load absorbed employments: %w", err) + } + type employmentMove struct { + id, organizationID int64 + titleNormalized string + current, primary bool + } + moves := []employmentMove{} + for rows.Next() { + var move employmentMove + if err := rows.Scan( + &move.id, &move.organizationID, &move.titleNormalized, + &move.current, &move.primary, + ); err != nil { + _ = rows.Close() + return fmt.Errorf("scan absorbed employment: %w", err) + } + moves = append(moves, move) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate absorbed employments: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close absorbed employments: %w", err) + } + + for _, move := range moves { + var duplicateID int64 + if move.current { + err := tx.QueryRowContext(ctx, `SELECT id FROM employments + WHERE person_id = ? AND organization_id = ? AND title_normalized = ? + AND `+s.dialect.BoolTrueExpr("is_current")+` + ORDER BY id LIMIT 1`, survivorID, move.organizationID, move.titleNormalized).Scan(&duplicateID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("find duplicate employment: %w", err) + } + } + if duplicateID > 0 { + if _, err := tx.ExecContext(ctx, `DELETE FROM employments WHERE id = ?`, move.id); err != nil { + return fmt.Errorf("delete duplicate employment: %w", err) + } + if err := s.setPersonMergeRowDispositionTx( + ctx, tx, mergeID, "employments", move.id, personMergeActionDeduplicated, &duplicateID, + ); err != nil { + return err + } + continue + } + primary := move.primary + if move.current && primary && survivorHasPrimary { + primary = false + } + if _, err := tx.ExecContext(ctx, `UPDATE employments + SET person_id = ?, is_primary = ?, revision = revision + 1, + updated_at = `+s.dialect.Now()+` + WHERE id = ?`, survivorID, primary, move.id); err != nil { + return fmt.Errorf("move absorbed employment: %w", err) + } + if move.current && primary { + survivorHasPrimary = true + } + } + return nil +} + +func (s *Store) reconcilePersonIdentityCandidatesTx( + ctx context.Context, tx *loggedTx, mergeID, survivorID, absorbedID int64, +) error { + candidateIDs, err := personMergeRowIDsTx(ctx, tx, `SELECT id + FROM identity_match_candidates + WHERE (left_kind = 'person' AND left_id = ?) + OR (right_kind = 'person' AND right_id = ?) + ORDER BY id`, absorbedID, absorbedID) + if err != nil { + return fmt.Errorf("load absorbed identity candidates: %w", err) + } + type candidateSource struct { + candidateID, sourceID int64 + originalKey string + } + sources := []candidateSource{} + for _, candidateID := range candidateIDs { + rows, err := tx.QueryContext(ctx, `SELECT source_id + FROM identity_match_candidate_sources + WHERE candidate_id = ? ORDER BY source_id`, candidateID) + if err != nil { + return fmt.Errorf("load absorbed candidate sources: %w", err) + } + for rows.Next() { + var sourceID int64 + if err := rows.Scan(&sourceID); err != nil { + _ = rows.Close() + return fmt.Errorf("scan absorbed candidate source: %w", err) + } + key, err := personMergeIntegerRowKey( + []string{"candidate_id", sourceIDColumnName}, candidateID, sourceID, + ) + if err != nil { + _ = rows.Close() + return err + } + sources = append(sources, candidateSource{ + candidateID: candidateID, sourceID: sourceID, originalKey: key, + }) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate absorbed candidate sources: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close absorbed candidate sources: %w", err) + } + } + type candidateEvidence struct { + candidateID, evidenceID int64 + sourceIDs []int64 + } + evidenceRows := []candidateEvidence{} + for _, candidateID := range candidateIDs { + rows, err := tx.QueryContext(ctx, `SELECT id + FROM identity_match_evidence WHERE candidate_id = ? ORDER BY id`, candidateID) + if err != nil { + return fmt.Errorf("load absorbed candidate evidence: %w", err) + } + for rows.Next() { + var evidenceID int64 + if err := rows.Scan(&evidenceID); err != nil { + _ = rows.Close() + return fmt.Errorf("scan absorbed candidate evidence: %w", err) + } + evidenceRows = append(evidenceRows, candidateEvidence{ + candidateID: candidateID, evidenceID: evidenceID, + }) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate absorbed candidate evidence: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close absorbed candidate evidence: %w", err) + } + } + for index := range evidenceRows { + evidenceRows[index].sourceIDs, err = personMergeRowIDsTx(ctx, tx, `SELECT source_id + FROM identity_match_evidence_sources + WHERE evidence_id = ? ORDER BY source_id`, evidenceRows[index].evidenceID) + if err != nil { + return fmt.Errorf("load absorbed evidence sources: %w", err) + } + } + if err := s.rewriteIdentityMatchCandidatesForEndpointMergeTx( + ctx, tx, IdentityMatchPerson, absorbedID, survivorID, nil, true, + ); err != nil { + return fmt.Errorf("rewrite person identity candidates: %w", err) + } + targets := make(map[int64]*int64, len(candidateIDs)) + for _, candidateID := range candidateIDs { + var exists bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM identity_match_candidates WHERE id = ? + )`, candidateID).Scan(&exists); err != nil { + return fmt.Errorf("inspect rewritten identity candidate: %w", err) + } + if exists { + target := candidateID + targets[candidateID] = &target + continue + } + survivingID, collapsed, found, err := identityMatchCandidateRedirectTx( + ctx, tx, candidateID, + ) + if err != nil { + return err + } + if !found { + return fmt.Errorf("%w: rewritten identity candidate has no redirect", + ErrPersonMergeInvalid) + } + if collapsed { + targets[candidateID] = nil + if err := s.setPersonMergeRowDispositionTx( + ctx, tx, mergeID, identityMatchCandidatesTableName, candidateID, + "deleted_snapshot", nil, + ); err != nil { + return err + } + continue + } + target := survivingID + targets[candidateID] = &target + if err := s.setPersonMergeRowDispositionTx( + ctx, tx, mergeID, identityMatchCandidatesTableName, candidateID, + personMergeActionDeduplicated, &survivingID, + ); err != nil { + return err + } + } + for _, source := range sources { + target := targets[source.candidateID] + if target != nil && *target == source.candidateID { + continue + } + var currentKey *string + action := "deleted_snapshot" + if target != nil { + key, err := personMergeIntegerRowKey( + []string{"candidate_id", sourceIDColumnName}, *target, source.sourceID, + ) + if err != nil { + return err + } + currentKey = &key + var targetExisted bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM person_merge_rows + WHERE merge_id = ? AND table_name = 'identity_match_candidate_sources' + AND original_row_key = ? + )`, mergeID, key).Scan(&targetExisted); err != nil { + return fmt.Errorf("inspect pre-merge candidate source target: %w", err) + } + action = "repointed" + if targetExisted { + action = personMergeActionDeduplicated + } + } + if err := s.setPersonMergeRowKeyDispositionTx( + ctx, tx, mergeID, identityMatchCandidateSourcesTableName, + source.originalKey, action, currentKey, + ); err != nil { + return err + } + } + for _, evidence := range evidenceRows { + if targets[evidence.candidateID] != nil { + continue + } + if err := s.setPersonMergeRowDispositionTx( + ctx, tx, mergeID, identityMatchEvidenceTableName, evidence.evidenceID, + "deleted_snapshot", nil, + ); err != nil { + return err + } + for _, sourceID := range evidence.sourceIDs { + key, err := personMergeIntegerRowKey( + []string{"evidence_id", sourceIDColumnName}, evidence.evidenceID, sourceID, + ) + if err != nil { + return err + } + if err := s.setPersonMergeRowKeyDispositionTx( + ctx, tx, mergeID, identityMatchEvidenceSourcesTableName, key, + "deleted_snapshot", nil, + ); err != nil { + return err + } + } + } + return nil +} + +func (s *Store) reconcilePersonDailyNotesTx( + ctx context.Context, tx *loggedTx, mergeID, survivorID, absorbedID int64, +) error { + entryIDs, err := personMergeRowIDsTx(ctx, tx, `SELECT entry_id + FROM daily_note_entry_persons WHERE person_id = ? ORDER BY entry_id`, absorbedID) + if err != nil { + return fmt.Errorf("load absorbed daily note targets: %w", err) + } + for _, entryID := range entryIDs { + originalKey, err := personMergeIntegerRowKey( + []string{"entry_id", "person_id"}, entryID, absorbedID, + ) + if err != nil { + return err + } + currentKey, err := personMergeIntegerRowKey( + []string{"entry_id", "person_id"}, entryID, survivorID, + ) + if err != nil { + return err + } + var duplicate bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM daily_note_entry_persons + WHERE entry_id = ? AND person_id = ? + )`, entryID, survivorID).Scan(&duplicate); err != nil { + return fmt.Errorf("find duplicate daily note target: %w", err) + } + action := "repointed" + if duplicate { + action = personMergeActionDeduplicated + if _, err := tx.ExecContext(ctx, `DELETE FROM daily_note_entry_persons + WHERE entry_id = ? AND person_id = ?`, entryID, absorbedID); err != nil { + return fmt.Errorf("delete duplicate daily note target: %w", err) + } + } else if _, err := tx.ExecContext(ctx, `UPDATE daily_note_entry_persons + SET person_id = ? WHERE entry_id = ? AND person_id = ?`, + survivorID, entryID, absorbedID); err != nil { + return fmt.Errorf("repoint daily note target: %w", err) + } + if err := s.setPersonMergeRowKeyDispositionTx( + ctx, tx, mergeID, "daily_note_entry_persons", originalKey, + action, ¤tKey, + ); err != nil { + return err + } + } + return nil +} + +func rewritePersonMergeParticipantLineageTx( + ctx context.Context, tx *loggedTx, absorbedParticipantID, survivorParticipantID int64, +) error { + var conflictingLineage bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 + FROM person_merge_participants absorbed + JOIN person_merge_participants survivor + ON survivor.merge_id = absorbed.merge_id + WHERE absorbed.participant_id = ? + AND survivor.participant_id = ? + AND (absorbed.origin_side <> survivor.origin_side + OR COALESCE(absorbed.split_id, 0) <> COALESCE(survivor.split_id, 0)) + )`, absorbedParticipantID, survivorParticipantID).Scan(&conflictingLineage); err != nil { + return fmt.Errorf("inspect participant consolidation merge lineage: %w", err) + } + if conflictingLineage { + return ErrPersonMergeLineageConflict + } + if _, err := tx.ExecContext(ctx, `UPDATE person_merge_participants + SET origin_side = CASE + WHEN origin_side = 'absorbed' OR 'absorbed' = ( + SELECT absorbed.origin_side + FROM person_merge_participants absorbed + WHERE absorbed.merge_id = person_merge_participants.merge_id + AND absorbed.participant_id = ? + ) THEN 'absorbed' + ELSE 'survivor' + END, + split_id = COALESCE(split_id, ( + SELECT absorbed.split_id + FROM person_merge_participants absorbed + WHERE absorbed.merge_id = person_merge_participants.merge_id + AND absorbed.participant_id = ? + )) + WHERE participant_id = ? + AND EXISTS ( + SELECT 1 FROM person_merge_participants absorbed + WHERE absorbed.merge_id = person_merge_participants.merge_id + AND absorbed.participant_id = ? + )`, absorbedParticipantID, absorbedParticipantID, + survivorParticipantID, absorbedParticipantID); err != nil { + return fmt.Errorf("combine person merge participant lineage: %w", err) + } + if _, err := tx.ExecContext(ctx, `DELETE FROM person_merge_participants + WHERE participant_id = ? + AND EXISTS ( + SELECT 1 FROM person_merge_participants survivor + WHERE survivor.merge_id = person_merge_participants.merge_id + AND survivor.participant_id = ? + )`, absorbedParticipantID, survivorParticipantID); err != nil { + return fmt.Errorf("deduplicate person merge participant lineage: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE person_merge_participants + SET participant_id = ? WHERE participant_id = ?`, + survivorParticipantID, absorbedParticipantID); err != nil { + return fmt.Errorf("repoint person merge participant lineage: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE person_merge_rows + SET participant_id = ? WHERE participant_id = ?`, + survivorParticipantID, absorbedParticipantID); err != nil { + return fmt.Errorf("repoint person merge row lineage: %w", err) + } + return nil +} + +func (s *Store) getPersonMergeTx( + ctx context.Context, tx *loggedTx, mergeID int64, +) (*PersonMerge, error) { + var ( + merge PersonMerge + currentID sql.NullInt64 + createdAt time.Time + ) + err := tx.QueryRowContext(ctx, `SELECT + id, survivor_person_id_at_merge, absorbed_person_id, current_person_id, + survivor_uid, absorbed_uid, survivor_revision_before, + absorbed_revision_before, survivor_revision_after, actor, + snapshot_version, snapshot_sha256, created_at + FROM person_merges WHERE id = ?`, mergeID).Scan( + &merge.ID, &merge.SurvivorPersonID, &merge.AbsorbedPersonID, ¤tID, + &merge.SurvivorVCardUID, &merge.AbsorbedVCardUID, + &merge.SurvivorRevisionBefore, &merge.AbsorbedRevisionBefore, + &merge.SurvivorRevisionAfter, &merge.Actor, &merge.SnapshotVersion, + &merge.SnapshotSHA256, &createdAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrPersonMergeNotFound + } + if err != nil { + return nil, fmt.Errorf("get person merge %d: %w", mergeID, err) + } + if currentID.Valid { + merge.CurrentPersonID = ¤tID.Int64 + } + merge.CreatedAt = createdAt + return &merge, nil +} + +func (s *Store) personMergeResultTx( + ctx context.Context, tx *loggedTx, merge *PersonMerge, +) (*PersonMergeResult, error) { + if merge.CurrentPersonID == nil { + return nil, ErrPersonNotFound + } + person, err := s.getPersonTx(ctx, tx, *merge.CurrentPersonID) + if err != nil { + return nil, err + } + candidates, err := listPersonMergeReviewCandidatesTx(ctx, tx, merge.ID) + if err != nil { + return nil, err + } + return &PersonMergeResult{ + Person: *person, Merge: *merge, ReviewCandidates: candidates, + }, nil +} + +func listPersonMergeReviewCandidatesTx( + ctx context.Context, tx *loggedTx, mergeID int64, +) ([]PersonMergeReviewCandidate, error) { + rows, err := tx.QueryContext(ctx, `SELECT + id, merge_id, survivor_person_id, definition_id, + survivor_value_id, absorbed_value_id, state, resolution_value_id, + reviewed_by, reviewed_at, created_at + FROM person_merge_review_candidates WHERE merge_id = ? ORDER BY id`, mergeID) + if err != nil { + return nil, fmt.Errorf("list person merge review candidates: %w", err) + } + defer func() { _ = rows.Close() }() + result := []PersonMergeReviewCandidate{} + for rows.Next() { + var ( + candidate PersonMergeReviewCandidate + resolution sql.NullInt64 + reviewedBy sql.NullString + reviewedAt sql.NullTime + ) + if err := rows.Scan( + &candidate.ID, &candidate.MergeID, &candidate.PersonID, + &candidate.DefinitionID, &candidate.SurvivorValueID, + &candidate.AbsorbedValueID, &candidate.State, &resolution, + &reviewedBy, &reviewedAt, &candidate.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scan person merge review candidate: %w", err) + } + if resolution.Valid { + candidate.ResolutionValueID = &resolution.Int64 + } + if reviewedBy.Valid { + candidate.ReviewedBy = &reviewedBy.String + } + if reviewedAt.Valid { + candidate.ReviewedAt = &reviewedAt.Time + } + result = append(result, candidate) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person merge review candidates: %w", err) + } + return result, nil +} diff --git a/internal/store/person_merges_test.go b/internal/store/person_merges_test.go new file mode 100644 index 000000000..61a03674a --- /dev/null +++ b/internal/store/person_merges_test.go @@ -0,0 +1,2456 @@ +package store_test + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/activity" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestMergePersons_RootsAndBindings(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + survivorParticipant := f.EnsureParticipant("merge-survivor@example.com", "Survivor", "example.com") + absorbedParticipant := f.EnsureParticipant("merge-absorbed@example.com", "Absorbed", "example.com") + survivor, created, err := f.Store.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + require.True(created) + absorbed, created, err := f.Store.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + require.True(created) + + survivor, err = f.Store.UpdatePersonDisplayNameContext( + context.Background(), survivor.ID, survivor.Revision, new("Kept Name"), + ) + require.NoError(err) + absorbedUID := absorbed.VCardUID + rawVCard := []byte("BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Absorbed\r\nEND:VCARD\r\n") + envelope := parseStoreEnvelope(t, rawVCard, "merge-book", "absorbed-card") + envelope.CanonicalPersonUID = absorbedUID + storedEnvelope, err := f.Store.PutVCardResourceEnvelopeContext( + context.Background(), store.VCardResourceEnvelopeInput{ + PersonID: absorbed.ID, Envelope: envelope, + }, + ) + require.NoError(err) + seedFullProfile(t, f.Store, absorbed.ID) + _, err = f.Store.RetirePersonUIDAliasContext( + context.Background(), "older-absorbed-alias", &absorbed.ID, "test", + ) + require.NoError(err) + absorbed, err = f.Store.GetPersonContext(context.Background(), absorbed.ID) + require.NoError(err) + + request := store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-roots-and-bindings", Actor: "test", + } + result, err := f.Store.MergePersonsContext(context.Background(), request) + require.NoError(err) + assert.Equal(survivor.ID, result.Person.ID) + assert.Equal(survivor.VCardUID, result.Person.VCardUID) + assert.Equal(new("Kept Name"), result.Person.DisplayName) + assert.Equal(survivor.Revision+1, result.Person.Revision) + assert.Equal([]int64{survivorParticipant, absorbedParticipant}, result.Person.ParticipantIDs) + assert.Equal(survivor.ID, result.Merge.SurvivorPersonID) + assert.Equal(absorbed.ID, result.Merge.AbsorbedPersonID) + assert.Equal(absorbedUID, result.Merge.AbsorbedVCardUID) + assert.Equal(1, result.Merge.SnapshotVersion) + assert.Len(result.Merge.SnapshotSHA256, 64) + + _, err = f.Store.GetPersonContext(context.Background(), absorbed.ID) + require.ErrorIs(err, store.ErrPersonNotFound) + alias, err := f.Store.ResolveRetiredPersonUIDContext(context.Background(), absorbedUID) + require.NoError(err) + require.NotNil(alias.SurvivingPersonID) + assert.Equal(survivor.ID, *alias.SurvivingPersonID) + + profile, err := f.Store.GetPersonProfileContext(context.Background(), survivor.ID) + require.NoError(err) + assert.Len(profile.Names, 2) + assert.Len(profile.ContactPoints, 2) + assert.Len(profile.Addresses, 1) + assert.Len(profile.Dates, 1) + assert.Len(profile.Categories, 1) + assert.Len(profile.Media, 1) + movedEnvelope, err := f.Store.GetVCardResourceEnvelopeContext( + context.Background(), "merge-book", "absorbed-card", + ) + require.NoError(err) + assert.Equal(storedEnvelope.ID, movedEnvelope.ID) + assert.Equal(storedEnvelope.Revision+1, movedEnvelope.Revision) + assert.Equal(survivor.ID, movedEnvelope.PersonID) + assert.Equal(survivor.VCardUID, movedEnvelope.CanonicalPersonUID) + assert.Equal(rawVCard, movedEnvelope.OriginalRawBytes) + staleRevision := storedEnvelope.Revision + _, err = f.Store.PutVCardResourceEnvelopeContext(context.Background(), store.VCardResourceEnvelopeInput{ + PersonID: survivor.ID, ExpectedRevision: &staleRevision, + Envelope: movedEnvelope.ResourceEnvelope, + }) + require.Error(err) + require.ErrorIs(err, store.ErrVCardResourceWriteConflict) + olderAlias, err := f.Store.ResolveRetiredPersonUIDContext( + context.Background(), "older-absorbed-alias", + ) + require.NoError(err) + require.NotNil(olderAlias.SurvivingPersonID) + assert.Equal(survivor.ID, *olderAlias.SurvivingPersonID) + var ( + aliasOriginalID sql.NullInt64 + aliasOriginalKey string + aliasAction string + ) + require.NoError(f.Store.DB().QueryRowContext(context.Background(), f.Store.Rebind(`SELECT + original_row_id, original_row_key, action + FROM person_merge_rows + WHERE merge_id = ? AND table_name = 'person_uid_aliases'`), result.Merge.ID).Scan( + &aliasOriginalID, &aliasOriginalKey, &aliasAction, + )) + assert.False(aliasOriginalID.Valid) + assert.NotEmpty(aliasOriginalKey) + assert.Equal("repointed", aliasAction) + + replayed, err := f.Store.MergePersonsContext(context.Background(), request) + require.NoError(err) + assert.Equal(result.Merge.ID, replayed.Merge.ID) + assert.Equal(result.Person.Revision, replayed.Person.Revision) + changedActor := request + changedActor.Actor = "different-actor" + _, err = f.Store.MergePersonsContext(context.Background(), changedActor) + require.ErrorIs(err, store.ErrPersonMergeIdempotency) + + changedRequest := request + changedRequest.AbsorbedID++ + _, err = f.Store.MergePersonsContext(context.Background(), changedRequest) + require.Error(err) + require.ErrorIs(err, store.ErrPersonMergeIdempotency) + + currentSurvivor, err := f.Store.GetPersonContext(context.Background(), result.Person.ID) + require.NoError(err) + _, err = f.Store.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: currentSurvivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: currentSurvivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-already-absorbed", Actor: "test", + }) + require.ErrorIs(err, store.ErrPersonNotFound) +} + +func TestMergePersons_CardDAVState(t *testing.T) { + t.Run("resource binding follows exact split", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st, _, book := newCardDAVResourceStore(t) + survivor := mustPromotedPerson(t, st, "carddav-merge-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "carddav-merge-absorbed@example.com", "Absorbed") + + var resourceID int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`INSERT INTO carddav_resources ( + address_book_id, href, remote_etag, remote_body, remote_semantic_hash, + local_hash, mapping_status, governance, person_id, person_revision_at_bind + ) VALUES (?, ?, ?, ?, ?, ?, 'mapped', 'local', ?, ?) RETURNING id`), + book.ID, book.CanonicalURL+"absorbed.vcf", `"etag"`, []byte("card"), + "remote-hash", "local-hash", absorbed.ID, absorbed.Revision, + ).Scan(&resourceID)) + + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "carddav-resource-merge", Actor: "test", + }) + require.NoError(err) + var ownerID int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind( + `SELECT person_id FROM carddav_resources WHERE id = ?`), resourceID).Scan(&ownerID)) + assert.Equal(survivor.ID, ownerID) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: []int64{absorbed.ParticipantIDs[0]}, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "carddav-resource-split", Actor: "test", + }) + require.NoError(err) + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind( + `SELECT person_id FROM carddav_resources WHERE id = ?`), resourceID).Scan(&ownerID)) + assert.Equal(split.NewPerson.ID, ownerID) + }) + + t.Run("publication blocks merge", func(t *testing.T) { + require := require.New(t) + ctx := context.Background() + st, _, _ := newCardDAVResourceStore(t) + survivor := mustPromotedPerson(t, st, "carddav-published-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "carddav-published-absorbed@example.com", "Absorbed") + _, err := st.DB().ExecContext(ctx, st.Rebind( + `INSERT INTO carddav_publications (person_id, desired) VALUES (?, TRUE)`), absorbed.ID) + require.NoError(err) + + _, err = st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "carddav-publication-merge", Actor: "test", + }) + require.ErrorIs(err, store.ErrPersonCardDAVPublished) + _, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + }) +} + +type personMergeInspectionFixture struct { + store *store.Store + person, unrelated *store.Person + absorbedParticipant int64 + merge *store.PersonMergeResult + survivorValueID int64 + absorbedValueID int64 +} + +func newPersonMergeInspectionFixture(t *testing.T, key string) personMergeInspectionFixture { + t.Helper() + require := require.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, key+"-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, key+"-absorbed@example.com", "Absorbed") + absorbedAlias, err := st.EnsureParticipant( + key+"-absorbed-alias@example.com", "Absorbed Alias", "example.com") + require.NoError(err) + _, err = st.LinkParticipants(absorbed.ParticipantIDs[0], absorbedAlias) + require.NoError(err) + unrelated := mustPromotedPerson(t, st, key+"-unrelated@example.com", "Unrelated") + _, err = st.AddPersonNameContext(ctx, absorbed.ID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Absorbed Name"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + survivorValue, err := st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: survivor.ID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: new("email")}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + absorbedValue, err := st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: absorbed.ID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: new("chat")}, + Source: store.ProvenanceVCardImport, + }) + require.NoError(err) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: key + "-merge", Actor: "test", + }) + require.NoError(err) + return personMergeInspectionFixture{ + store: st, person: &merged.Person, unrelated: unrelated, + absorbedParticipant: absorbed.ParticipantIDs[0], merge: merged, + survivorValueID: survivorValue.Value.ID, absorbedValueID: absorbedValue.Value.ID, + } +} + +type personMergeRecordReferenceFixture struct { + store *store.Store + absorbed *store.Person + absorbedTarget *store.Person + merge *store.PersonMergeResult + absorbedValueID int64 +} + +func newPersonMergeRecordReferenceFixture( + t *testing.T, key string, +) personMergeRecordReferenceFixture { + t.Helper() + require := require.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, key+"-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, key+"-absorbed@example.com", "Absorbed") + survivorTarget := mustPromotedPerson(t, st, + key+"-survivor-target@example.com", "Survivor Target") + absorbedTarget := mustPromotedPerson(t, st, + key+"-absorbed-target@example.com", "Absorbed Target") + definition := personTextDefinition(strings.ReplaceAll(key, "-", "_") + "_record_reference") + definition.ValueType = store.AttributeValueRecordReference + definition.FieldType = store.AttributeFieldPerson + definition.RecordTarget = new("person") + _, err := st.CreateAttributeDefinitionContext(ctx, definition) + require.NoError(err) + var absorbedValueID int64 + for personID, targetID := range map[int64]int64{ + survivor.ID: survivorTarget.ID, + absorbed.ID: absorbedTarget.ID, + } { + write, writeErr := st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: definition.Slug, + Value: store.AttributeValue{ + Type: store.AttributeValueRecordReference, + RecordType: new("person"), RecordID: &targetID, + }, + Source: store.ProvenanceUser, + }) + require.NoError(writeErr) + if personID == absorbed.ID { + absorbedValueID = write.Value.ID + } + } + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: key + "-merge", Actor: "test", + }) + require.NoError(err) + require.Len(merged.ReviewCandidates, 1) + return personMergeRecordReferenceFixture{ + store: st, absorbed: absorbed, absorbedTarget: absorbedTarget, + merge: merged, absorbedValueID: absorbedValueID, + } +} + +func assertJSONEquivalent(t *testing.T, want, got any, msgAndArgs ...any) { + t.Helper() + wantJSON, err := json.Marshal(want) + require.NoError(t, err) + gotJSON, err := json.Marshal(got) + require.NoError(t, err) + assert.JSONEq(t, string(wantJSON), string(gotJSON), msgAndArgs...) +} + +func TestPersonMerge_Inspect(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonMergeInspectionFixture(t, "inspect") + ctx := context.Background() + split, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: f.person.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: []int64{f.absorbedParticipant}, + ExpectedSourceRevision: f.person.Revision, + IdempotencyKey: "inspect-split", Actor: "test", + }) + require.NoError(err) + list, err := f.store.ListPersonMergesContext(ctx, split.SourcePerson.ID) + require.NoError(err) + require.Len(list, 1) + assert.Equal(f.merge.Merge.ID, list[0].Merge.ID) + assert.Equal(3, list[0].ParticipantCount) + assert.Equal(1, list[0].SplitCount) + assert.Equal(1, list[0].PendingCandidateCount) + assert.NotEmpty(list[0].RowActionCounts) + newPersonList, err := f.store.ListPersonMergesContext(ctx, split.NewPerson.ID) + require.NoError(err) + require.Len(newPersonList, 1) + unrelated, err := f.store.ListPersonMergesContext(ctx, f.unrelated.ID) + require.NoError(err) + assert.Empty(unrelated) + + detail, err := f.store.GetPersonMergeContext(ctx, f.merge.Merge.ID) + require.NoError(err) + assert.Equal(f.merge.Merge.ID, detail.Merge.ID) + assert.Len(detail.Participants, 3) + assert.NotEmpty(detail.Rows) + assert.Len(detail.Splits, 1) + assert.Len(detail.ReviewCandidates, 1) +} + +func TestPersonMerge_InspectNewestFirst(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "inspect-order-survivor@example.com", "Survivor") + mergeIDs := []int64{} + for index := range 2 { + absorbed := mustPromotedPerson(t, st, + fmt.Sprintf("inspect-order-%d@example.com", index), "Absorbed") + survivor, _ = st.GetPersonContext(ctx, survivor.ID) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: fmt.Sprintf("inspect-order-%d", index), Actor: "test", + }) + require.NoError(err) + survivor = &merged.Person + mergeIDs = append(mergeIDs, merged.Merge.ID) + } + list, err := st.ListPersonMergesContext(ctx, survivor.ID) + require.NoError(err) + require.Len(list, 2) + assert.Equal([]int64{mergeIDs[1], mergeIDs[0]}, + []int64{list[0].Merge.ID, list[1].Merge.ID}) +} + +func TestPersonMerge_Snapshot(t *testing.T) { + assert := assert.New(t) + f := newPersonMergeInspectionFixture(t, "snapshot-read") + ctx := context.Background() + response, err := f.store.GetPersonMergeSnapshotContext(ctx, f.merge.Merge.ID) + require.NoError(t, err) + assert.Equal(f.merge.Merge.SnapshotVersion, response.Version) + assert.Equal(f.merge.Merge.SnapshotSHA256, response.SHA256) + assert.True(json.Valid(response.JSON)) + assert.Contains(string(response.JSON), `"persons"`) + _, err = f.store.DB().ExecContext(ctx, f.store.Rebind(`UPDATE person_merges + SET snapshot_blob = ? WHERE id = ?`), []byte("corrupt"), f.merge.Merge.ID) + require.NoError(t, err) + _, err = f.store.GetPersonMergeSnapshotContext(ctx, f.merge.Merge.ID) + require.ErrorIs(t, err, store.ErrPersonMergeSnapshotCorrupt) +} + +func TestPersonMerge_CandidateDecisionAcceptedAndIdempotent(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonMergeInspectionFixture(t, "candidate-accept") + ctx := context.Background() + request := store.PersonMergeCandidateDecisionRequest{ + CandidateID: f.merge.ReviewCandidates[0].ID, PersonID: f.person.ID, + ExpectedPersonRevision: f.person.Revision, + Decision: store.PersonMergeCandidateAccept, Actor: "reviewer", + } + accepted, err := f.store.DecidePersonMergeCandidateContext(ctx, request) + require.NoError(err) + assert.Equal(f.person.Revision+1, accepted.PersonRevision) + assert.Equal("accepted", accepted.State) + assert.Equal(new("reviewer"), accepted.ReviewedBy) + assert.NotNil(accepted.ReviewedAt) + require.NotNil(accepted.ResolutionValueID) + assert.NotEqual(f.survivorValueID, *accepted.ResolutionValueID) + values, err := f.store.ListPersonAttributeValuesContext(ctx, f.person.ID, + store.PersonAttributeQuery{DefinitionSlug: store.AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(values, 1) + require.NotNil(values[0].Value.Text) + assert.Equal("chat", *values[0].Value.Text) + history, err := f.store.ListPersonAttributeValuesContext(ctx, f.person.ID, + store.PersonAttributeQuery{ + DefinitionSlug: store.AttributeSlugPrimaryChannel, IncludeHistory: true, + }) + require.NoError(err) + require.Len(history, 3) + historyIDs := make([]int64, 0, len(history)) + for _, value := range history { + historyIDs = append(historyIDs, value.ID) + } + assert.Contains(historyIDs, f.survivorValueID) + assert.Contains(historyIDs, f.absorbedValueID) + assert.Contains(historyIDs, *accepted.ResolutionValueID) + current, err := f.store.GetPersonContext(ctx, f.person.ID) + require.NoError(err) + assert.Equal(f.person.Revision+1, current.Revision) + replayed, err := f.store.DecidePersonMergeCandidateContext(ctx, request) + require.NoError(err) + assertJSONEquivalent(t, accepted, replayed) + assert.Equal(current.Revision, replayed.PersonRevision) + afterReplay, err := f.store.GetPersonContext(ctx, f.person.ID) + require.NoError(err) + assert.Equal(current.Revision, afterReplay.Revision) +} + +func TestPersonMerge_PendingRecordReferenceCandidateBlocksTargetDeletion(t *testing.T) { + require := require.New(t) + ctx := context.Background() + f := newPersonMergeRecordReferenceFixture(t, "candidate-target-delete") + target, err := f.store.GetPersonContext(ctx, f.absorbedTarget.ID) + require.NoError(err) + err = f.store.DeletePersonContext(ctx, target.ID, target.Revision) + require.ErrorIs(err, store.ErrPersonReferenced) + _, err = f.store.GetPersonContext(ctx, target.ID) + require.NoError(err) + accepted, err := f.store.DecidePersonMergeCandidateContext(ctx, + store.PersonMergeCandidateDecisionRequest{ + CandidateID: f.merge.ReviewCandidates[0].ID, PersonID: f.merge.Person.ID, + ExpectedPersonRevision: f.merge.Person.Revision, + Decision: store.PersonMergeCandidateAccept, Actor: "reviewer", + }) + require.NoError(err) + require.Equal("accepted", accepted.State) +} + +func TestPersonMerge_CandidateDecisionRejectedAndConflicts(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonMergeInspectionFixture(t, "candidate-reject") + ctx := context.Background() + base := store.PersonMergeCandidateDecisionRequest{ + CandidateID: f.merge.ReviewCandidates[0].ID, PersonID: f.person.ID, + ExpectedPersonRevision: f.person.Revision, + Decision: store.PersonMergeCandidateReject, Actor: "reviewer", + } + stale := base + stale.ExpectedPersonRevision++ + _, err := f.store.DecidePersonMergeCandidateContext(ctx, stale) + require.ErrorIs(err, store.ErrPersonRevisionConflict) + wrongPerson := base + wrongPerson.PersonID = f.unrelated.ID + wrongPerson.ExpectedPersonRevision = f.unrelated.Revision + _, err = f.store.DecidePersonMergeCandidateContext(ctx, wrongPerson) + require.ErrorIs(err, store.ErrPersonMergeCandidateState) + rejected, err := f.store.DecidePersonMergeCandidateContext(ctx, base) + require.NoError(err) + assert.Equal("rejected", rejected.State) + assert.Equal(new("reviewer"), rejected.ReviewedBy) + assert.NotNil(rejected.ReviewedAt) + assert.Nil(rejected.ResolutionValueID) + values, err := f.store.ListPersonAttributeValuesContext(ctx, f.person.ID, + store.PersonAttributeQuery{DefinitionSlug: store.AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(values, 1) + require.NotNil(values[0].Value.Text) + assert.Equal("email", *values[0].Value.Text) + changed := base + changed.Decision = store.PersonMergeCandidateAccept + _, err = f.store.DecidePersonMergeCandidateContext(ctx, changed) + require.ErrorIs(err, store.ErrPersonMergeCandidateState) +} + +func TestPersonMerge_CandidateDecisionRejectsChangedCurrentValue(t *testing.T) { + require := require.New(t) + f := newPersonMergeInspectionFixture(t, "candidate-current-changed") + ctx := context.Background() + _, err := f.store.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: f.person.ID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: new("phone")}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + _, err = f.store.DecidePersonMergeCandidateContext(ctx, + store.PersonMergeCandidateDecisionRequest{ + CandidateID: f.merge.ReviewCandidates[0].ID, PersonID: f.person.ID, + ExpectedPersonRevision: f.person.Revision, + Decision: store.PersonMergeCandidateAccept, Actor: "reviewer", + }) + require.ErrorIs(err, store.ErrPersonMergeCandidateState) + current, err := f.store.ListPersonAttributeValuesContext(ctx, f.person.ID, + store.PersonAttributeQuery{DefinitionSlug: store.AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(current, 1) + require.NotNil(current[0].Value.Text) + assert.Equal(t, "phone", *current[0].Value.Text) +} + +func TestPersonMerge_CandidateDecisionRejectsInactiveDefinitionWithoutMutation(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + f := newPersonMergeInspectionFixture(t, "candidate-inactive-definition") + definition, err := f.store.GetAttributeDefinitionBySlugContext( + ctx, store.AttributeObjectPerson, store.AttributeSlugPrimaryChannel) + require.NoError(err) + _, err = f.store.UpdateAttributeDefinitionContext(ctx, definition.ID, definition.Revision, + store.AttributeDefinitionUpdate{IsActive: new(false)}) + require.NoError(err) + + _, err = f.store.DecidePersonMergeCandidateContext(ctx, + store.PersonMergeCandidateDecisionRequest{ + CandidateID: f.merge.ReviewCandidates[0].ID, PersonID: f.person.ID, + ExpectedPersonRevision: f.person.Revision, + Decision: store.PersonMergeCandidateAccept, Actor: "reviewer", + }) + require.ErrorIs(err, store.ErrPersonMergeCandidateState) + require.ErrorIs(err, store.ErrAttributeDefinitionInactive) + current, err := f.store.GetPersonContext(ctx, f.person.ID) + require.NoError(err) + assert.Equal(f.person.Revision, current.Revision) + values, err := f.store.ListPersonAttributeValuesContext(ctx, f.person.ID, + store.PersonAttributeQuery{ + DefinitionSlug: store.AttributeSlugPrimaryChannel, IncludeHistory: true, + }) + require.NoError(err) + require.Len(values, 2) + var currentValues int + for _, value := range values { + if value.ActiveUntil == nil { + currentValues++ + require.NotNil(value.Value.Text) + assert.Equal("email", *value.Value.Text) + } + } + assert.Equal(1, currentValues) + detail, err := f.store.GetPersonMergeContext(ctx, f.merge.Merge.ID) + require.NoError(err) + require.Len(detail.ReviewCandidates, 1) + assert.Equal("pending", detail.ReviewCandidates[0].State) + assert.Nil(detail.ReviewCandidates[0].ResolutionValueID) + assert.Nil(detail.ReviewCandidates[0].ReviewedBy) + assert.Nil(detail.ReviewCandidates[0].ReviewedAt) +} + +func TestPersonMerge_CandidateDecisionMissingCandidate(t *testing.T) { + f := newPersonMergeInspectionFixture(t, "candidate-missing") + _, err := f.store.DecidePersonMergeCandidateContext(context.Background(), + store.PersonMergeCandidateDecisionRequest{ + CandidateID: f.merge.ReviewCandidates[0].ID + 1_000_000, + PersonID: f.person.ID, ExpectedPersonRevision: f.person.Revision, + Decision: store.PersonMergeCandidateReject, Actor: "reviewer", + }) + require.ErrorIs(t, err, store.ErrPersonMergeCandidateNotFound) +} + +func TestMergePersons_RevisionConflictChangesNothing(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + survivorParticipant := f.EnsureParticipant("stale-survivor@example.com", "Survivor", "example.com") + absorbedParticipant := f.EnsureParticipant("stale-absorbed@example.com", "Absorbed", "example.com") + survivor, _, err := f.Store.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := f.Store.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + _, err = f.Store.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision + 1, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-stale-revision", Actor: "test", + }) + require.Error(err) + require.ErrorIs(err, store.ErrPersonRevisionConflict) + + unchangedSurvivor, err := f.Store.GetPersonContext(context.Background(), survivor.ID) + require.NoError(err) + assert.Equal([]int64{survivorParticipant}, unchangedSurvivor.ParticipantIDs) + unchangedAbsorbed, err := f.Store.GetPersonContext(context.Background(), absorbed.ID) + require.NoError(err) + assert.Equal([]int64{absorbedParticipant}, unchangedAbsorbed.ParticipantIDs) + _, err = f.Store.ResolveRetiredPersonUIDContext(context.Background(), absorbed.VCardUID) + require.ErrorIs(err, store.ErrPersonUIDAliasNotFound) +} + +func TestMergePersons_Facts(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + survivorParticipant := f.EnsureParticipant("facts-survivor@example.com", "Survivor", "example.com") + absorbedParticipant := f.EnsureParticipant("facts-absorbed@example.com", "Absorbed", "example.com") + survivor, _, err := f.Store.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := f.Store.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + observerParticipant := f.EnsureParticipant("facts-observer@example.com", "Observer", "example.com") + observer, _, err := f.Store.CreatePersonFromParticipant(observerParticipant) + require.NoError(err) + _, err = f.Store.AddPersonCategoryContext(context.Background(), survivor.ID, store.PersonCategoryInput{ + OriginalValue: "Friends", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + _, err = f.Store.AddPersonCategoryContext(context.Background(), absorbed.ID, store.PersonCategoryInput{ + OriginalValue: "friends", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport}, + }) + require.NoError(err) + var absorbedCategoryID int64 + require.NoError(f.Store.DB().QueryRowContext(context.Background(), f.Store.Rebind(`SELECT id + FROM person_categories WHERE person_id = ? AND normalized_value = 'friends'`), + absorbed.ID).Scan(&absorbedCategoryID)) + + survivorChannel, err := f.Store.SetPersonAttributeValueContext(context.Background(), store.PersonAttributeValueInput{ + PersonID: survivor.ID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: new("email")}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + absorbedChannel, err := f.Store.SetPersonAttributeValueContext(context.Background(), store.PersonAttributeValueInput{ + PersonID: absorbed.ID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: new("chat")}, + Source: store.ProvenanceVCardImport, + }) + require.NoError(err) + for personID, topic := range map[int64]string{ + survivor.ID: "music", absorbed.ID: "music", + } { + _, err = f.Store.SetPersonAttributeValueContext(context.Background(), store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: store.AttributeSlugAskMeAbout, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: &topic}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + gardening := "gardening" + _, err = f.Store.SetPersonAttributeValueContext(context.Background(), store.PersonAttributeValueInput{ + PersonID: absorbed.ID, DefinitionSlug: store.AttributeSlugAskMeAbout, + Ordinal: new(int64(1)), + Value: store.AttributeValue{Type: store.AttributeValueText, Text: &gardening}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + jsonDefinition := personTextDefinition("merge_json_equivalence") + jsonDefinition.UniversalID = "test-merge-json-equivalence" + jsonDefinition.ValueType = store.AttributeValueJSON + jsonDefinition.FieldType = store.AttributeFieldJSON + _, err = f.Store.CreateAttributeDefinitionContext(context.Background(), jsonDefinition) + require.NoError(err) + for personID, value := range map[int64]json.RawMessage{ + survivor.ID: json.RawMessage(`{"a":1,"b":2}`), + absorbed.ID: json.RawMessage(`{"b":2,"a":1}`), + } { + _, err = f.Store.SetPersonAttributeValueContext(context.Background(), store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: jsonDefinition.Slug, + Value: store.AttributeValue{Type: store.AttributeValueJSON, JSON: value}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + relatedDefinition := personTextDefinition("merge_related_person") + relatedDefinition.UniversalID = "test-merge-related-person" + relatedDefinition.ValueType = store.AttributeValueRecordReference + relatedDefinition.FieldType = store.AttributeFieldPerson + relatedDefinition.RecordTarget = new("person") + _, err = f.Store.CreateAttributeDefinitionContext(context.Background(), relatedDefinition) + require.NoError(err) + relatedWrite, err := f.Store.SetPersonAttributeValueContext(context.Background(), store.PersonAttributeValueInput{ + PersonID: observer.ID, DefinitionSlug: relatedDefinition.Slug, + Value: store.AttributeValue{ + Type: store.AttributeValueRecordReference, RecordType: new("person"), RecordID: &absorbed.ID, + }, + Source: store.ProvenanceUser, + }) + require.NoError(err) + var observerProjectionBefore int64 + require.NoError(f.Store.DB().QueryRowContext(context.Background(), f.Store.Rebind(`SELECT + vcard_projection_revision FROM persons WHERE id = ?`), observer.ID).Scan(&observerProjectionBefore)) + survivor, err = f.Store.GetPersonContext(context.Background(), survivor.ID) + require.NoError(err) + absorbed, err = f.Store.GetPersonContext(context.Background(), absorbed.ID) + require.NoError(err) + + result, err := f.Store.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-facts", Actor: "test", + }) + require.NoError(err) + require.Len(result.ReviewCandidates, 1) + candidate := result.ReviewCandidates[0] + assert.Equal("pending", candidate.State) + assert.Equal(survivorChannel.Value.ID, candidate.SurvivorValueID) + assert.Equal(absorbedChannel.Value.ID, candidate.AbsorbedValueID) + + currentChannels, err := f.Store.ListPersonAttributeValuesContext(context.Background(), survivor.ID, + store.PersonAttributeQuery{DefinitionSlug: store.AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(currentChannels, 1) + assert.Equal("email", *currentChannels[0].Value.Text) + historicalChannels, err := f.Store.ListPersonAttributeValuesContext(context.Background(), survivor.ID, + store.PersonAttributeQuery{DefinitionSlug: store.AttributeSlugPrimaryChannel, IncludeHistory: true}) + require.NoError(err) + assert.Len(historicalChannels, 2) + + topics, err := f.Store.ListPersonAttributeValuesContext(context.Background(), survivor.ID, + store.PersonAttributeQuery{DefinitionSlug: store.AttributeSlugAskMeAbout}) + require.NoError(err) + require.Len(topics, 2) + assert.Equal("music", *topics[0].Value.Text) + assert.Equal("gardening", *topics[1].Value.Text) + + profile, err := f.Store.GetPersonProfileContext(context.Background(), survivor.ID) + require.NoError(err) + assert.Len(profile.Categories, 1) + history, err := f.Store.GetPersonProfileHistoryContext(context.Background(), survivor.ID) + require.NoError(err) + assert.Len(history.Categories, 2) + var ( + categoryCurrentID int64 + categoryKey string + categoryAction string + ) + require.NoError(f.Store.DB().QueryRowContext(context.Background(), f.Store.Rebind(`SELECT + current_row_id, original_row_key, action + FROM person_merge_rows + WHERE merge_id = ? AND table_name = 'person_categories' AND original_row_id = ?`), + result.Merge.ID, absorbedCategoryID).Scan( + &categoryCurrentID, &categoryKey, &categoryAction, + )) + assert.Equal(absorbedCategoryID, categoryCurrentID) + assert.NotEmpty(categoryKey) + assert.Equal("deduplicated", categoryAction) + var ( + relatedRecordID int64 + relatedAction string + relatedProvenance string + ) + require.NoError(f.Store.DB().QueryRowContext(context.Background(), f.Store.Rebind(`SELECT + value.value_record_id, journal.action, journal.provenance_kind + FROM person_attribute_values value + JOIN person_merge_rows journal + ON journal.table_name = 'person_attribute_values' + AND journal.original_row_id = value.id + WHERE journal.merge_id = ? AND value.id = ?`), + result.Merge.ID, relatedWrite.Value.ID).Scan( + &relatedRecordID, &relatedAction, &relatedProvenance, + )) + assert.Equal(survivor.ID, relatedRecordID) + assert.Equal("repointed", relatedAction) + assert.Equal("inbound_reference", relatedProvenance) + var observerProjectionAfter int64 + require.NoError(f.Store.DB().QueryRowContext(context.Background(), f.Store.Rebind(`SELECT + vcard_projection_revision FROM persons WHERE id = ?`), observer.ID).Scan(&observerProjectionAfter)) + assert.Equal(observerProjectionBefore+1, observerProjectionAfter) +} + +func TestMergePersons_StructuredPropertyIdentityCollision(t *testing.T) { + require := require.New(t) + f := storetest.New(t) + survivorParticipant := f.EnsureParticipant("property-survivor@example.com", "Survivor", "example.com") + absorbedParticipant := f.EnsureParticipant("property-absorbed@example.com", "Absorbed", "example.com") + survivor, _, err := f.Store.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := f.Store.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + closedAt := time.Now().UTC().Add(-time.Hour) + for _, value := range []struct { + personID int64 + name string + resourceID string + until *time.Time + }{ + {personID: survivor.ID, name: "Current Name", resourceID: "resource-a"}, + {personID: absorbed.ID, name: "Historical Name", resourceID: "resource-a", until: &closedAt}, + {personID: absorbed.ID, name: "Distinct Resource Name", resourceID: "resource-b"}, + } { + _, err = f.Store.AddPersonNameContext(context.Background(), value.personID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: &value.name, + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceVCardImport, SourceRef: new("shared-book"), + SourceResourceUID: &value.resourceID, + VCard: store.VCardIdentity{Property: "FN", PropID: new("shared-property")}, + ActiveUntil: value.until, + }, + }) + require.NoError(err) + } + survivor, err = f.Store.GetPersonContext(context.Background(), survivor.ID) + require.NoError(err) + absorbed, err = f.Store.GetPersonContext(context.Background(), absorbed.ID) + require.NoError(err) + + result, err := f.Store.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-property-identity", Actor: "test", + }) + require.NoError(err) + var action string + require.NoError(f.Store.DB().QueryRowContext(context.Background(), f.Store.Rebind(`SELECT action + FROM person_merge_rows + WHERE merge_id = ? AND table_name = 'person_names' AND original_row_id = ( + SELECT id FROM person_names + WHERE person_id = ? AND original_value = 'Historical Name' + )`), result.Merge.ID, survivor.ID).Scan(&action)) + assert.Equal(t, "deduplicated", action) + var distinctAction string + require.NoError(f.Store.DB().QueryRowContext(context.Background(), f.Store.Rebind(`SELECT journal.action + FROM person_merge_rows journal + JOIN person_names name ON name.id = journal.current_row_id + WHERE journal.merge_id = ? AND journal.table_name = 'person_names' + AND name.original_value = 'Distinct Resource Name'`), result.Merge.ID).Scan(&distinctAction)) + assert.Equal(t, "moved", distinctAction, + "the same property ID on another source resource remains distinct") +} + +func TestMergePersons_RelationshipsAndReviews(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "merge-rel-survivor@example.com", "Survivor") + other := mustPromotedPerson(t, st, "merge-rel-other@example.com", "Other") + absorbed := mustPromotedPerson(t, st, "merge-rel-absorbed@example.com", "Absorbed") + + related := func(personID int64) *store.RelatedResolution { + t.Helper() + resolution, err := st.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: personID, RawValue: other.VCardUID, RawType: "agent", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, + SourceRef: new("shared-related.vcf"), Actor: "test", + }) + require.NoError(err) + require.NotNil(resolution.Relationship) + require.NotNil(resolution.Review) + return resolution + } + survivorRelated := related(survivor.ID) + absorbedRelated := related(absorbed.ID) + incoming, err := st.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: other.ID, TargetPersonID: absorbed.ID, TypeSlug: "parent", + Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + selfAfterMerge, err := st.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: survivor.ID, TargetPersonID: absorbed.ID, TypeSlug: "friend", + Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + symmetricMove, err := st.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: absorbed.ID, TargetPersonID: other.ID, TypeSlug: "spouse", + Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + + selfReview, err := st.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: survivor.ID, RawValue: absorbed.VCardUID, RawType: "unknown", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, + SourceRef: new("self-after-merge.vcf"), Actor: "test", + }) + require.NoError(err) + require.NotNil(selfReview.Review) + require.NotNil(selfReview.Review.MatchedPersonID) + rejectedResolution, err := st.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: absorbed.ID, RawValue: "Not an identity", RawType: "unknown", + ValueKind: store.RelatedValueKindText, Source: store.ProvenanceVCardImport, + SourceRef: new("rejected-before-merge.vcf"), Actor: "test", + }) + require.NoError(err) + require.NotNil(rejectedResolution.Review) + rejectedReview, err := st.RejectRelationshipReviewContext( + ctx, rejectedResolution.Review.ID, "reviewer", + ) + require.NoError(err) + assert.Equal(store.RelationshipReviewRejected, rejectedReview.Status) + resourceReview := func(personID int64, resourceUID string) *store.RelationshipReview { + t.Helper() + resolution, err := st.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: personID, RawValue: "Resource-scoped unresolved identity", + RawType: "unknown", ValueKind: store.RelatedValueKindText, + Source: store.ProvenanceVCardImport, SourceRef: new("shared-related.vcf"), + SourceResourceUID: &resourceUID, Actor: "test", + }) + require.NoError(err) + require.Nil(resolution.Relationship) + require.NotNil(resolution.Review) + return resolution.Review + } + survivorResourceReview := resourceReview(survivor.ID, "survivor-card") + absorbedResourceReview := resourceReview(absorbed.ID, "absorbed-card") + + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + result, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-relationships-and-reviews", Actor: "test", + }) + require.NoError(err) + + relationships, err := st.ListPersonRelationshipsContext(ctx, survivor.ID, store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(relationships, 3) + byRelationshipID := make(map[int64]store.PersonRelationshipView, len(relationships)) + for _, relationship := range relationships { + byRelationshipID[relationship.Relationship.ID] = relationship + } + assert.Equal(other.ID, byRelationshipID[survivorRelated.Relationship.ID].CounterpartPersonID) + assert.Equal(other.ID, byRelationshipID[incoming.ID].CounterpartPersonID) + assert.Equal(survivor.ID, byRelationshipID[incoming.ID].Relationship.TargetPersonID) + assert.Equal(survivor.ID, byRelationshipID[symmetricMove.ID].Relationship.SourcePersonID) + assert.Equal(other.ID, byRelationshipID[symmetricMove.ID].Relationship.TargetPersonID) + + reviews, err := st.ListRelationshipReviewsContext(ctx, store.RelationshipReviewListOptions{PersonID: survivor.ID}) + require.NoError(err) + require.Len(reviews, 5) + byReviewID := make(map[int64]store.RelationshipReview, len(reviews)) + for _, review := range reviews { + byReviewID[review.ID] = review + } + assert.Equal(survivorRelated.Relationship.ID, *byReviewID[survivorRelated.Review.ID].AcceptedRelationshipID) + assert.Nil(byReviewID[selfReview.Review.ID].MatchedPersonID) + assert.Equal(store.RelationshipReviewRejected, byReviewID[rejectedReview.ID].Status) + require.NotNil(byReviewID[survivorResourceReview.ID].SourceResourceUID) + assert.Equal("survivor-card", *byReviewID[survivorResourceReview.ID].SourceResourceUID) + require.NotNil(byReviewID[absorbedResourceReview.ID].SourceResourceUID) + assert.Equal("absorbed-card", *byReviewID[absorbedResourceReview.ID].SourceResourceUID) + + for _, want := range []struct { + table string + original int64 + action string + currentID *int64 + }{ + {table: "person_relationships", original: absorbedRelated.Relationship.ID, action: "deduplicated", currentID: &survivorRelated.Relationship.ID}, + {table: "person_relationships", original: selfAfterMerge.ID, action: "deleted_snapshot"}, + {table: "person_relationship_reviews", original: absorbedRelated.Review.ID, action: "deduplicated", currentID: &survivorRelated.Review.ID}, + } { + var currentID sql.NullInt64 + var action string + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT current_row_id, action + FROM person_merge_rows + WHERE merge_id = ? AND table_name = ? AND original_row_id = ?`), + result.Merge.ID, want.table, want.original).Scan(¤tID, &action)) + assert.Equal(want.action, action) + if want.currentID == nil { + assert.False(currentID.Valid) + } else { + require.True(currentID.Valid) + assert.Equal(*want.currentID, currentID.Int64) + } + } +} + +func TestMergePersons_Employments(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "merge-job-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "merge-job-absorbed@example.com", "Absorbed") + sharedOrg := mustOrganization(t, st, "Shared Merge Employer") + absorbedPrimaryOrg := mustOrganization(t, st, "Absorbed Primary Employer") + historicalOrg := mustOrganization(t, st, "Historical Merge Employer") + + survivorPrimary, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: survivor.ID, OrganizationID: sharedOrg.ID, Title: new("Engineer"), + IsPrimary: new(true), Source: store.ProvenanceUser, + }) + require.NoError(err) + absorbedPrimary, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: absorbed.ID, OrganizationID: absorbedPrimaryOrg.ID, Title: new("Advisor"), + IsPrimary: new(true), Source: store.ProvenanceUser, + }) + require.NoError(err) + absorbedDuplicate, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: absorbed.ID, OrganizationID: sharedOrg.ID, Title: new("Engineer"), + IsPrimary: new(false), Source: store.ProvenanceUser, + }) + require.NoError(err) + absorbedHistory, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: absorbed.ID, OrganizationID: historicalOrg.ID, Title: new("Intern"), + IsCurrent: new(false), Source: store.ProvenanceUser, + }) + require.NoError(err) + + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + result, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-employments", Actor: "test", + }) + require.NoError(err) + + employments, err := st.ListEmploymentsContext(ctx, store.EmploymentFilter{PersonID: survivor.ID}) + require.NoError(err) + require.Len(employments, 3) + byID := make(map[int64]store.Employment, len(employments)) + for _, employment := range employments { + byID[employment.ID] = employment + } + assert.True(byID[survivorPrimary.ID].IsPrimary) + assert.False(byID[absorbedPrimary.ID].IsPrimary) + assert.True(byID[absorbedPrimary.ID].IsCurrent) + assert.Equal(absorbedPrimary.Revision+1, byID[absorbedPrimary.ID].Revision) + assert.False(byID[absorbedHistory.ID].IsCurrent) + _, duplicateExists := byID[absorbedDuplicate.ID] + assert.False(duplicateExists) + + var currentID sql.NullInt64 + var action string + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT current_row_id, action + FROM person_merge_rows + WHERE merge_id = ? AND table_name = 'employments' AND original_row_id = ?`), + result.Merge.ID, absorbedDuplicate.ID).Scan(¤tID, &action)) + require.True(currentID.Valid) + assert.Equal(survivorPrimary.ID, currentID.Int64) + assert.Equal("deduplicated", action) +} + +func TestMergePersons_InboundReferences(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "merge-ref-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "merge-ref-absorbed@example.com", "Absorbed") + observer := mustPromotedPerson(t, st, "merge-ref-observer@example.com", "Observer") + organization := mustOrganization(t, st, "Merge Reference Organization") + _, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: observer.ID, OrganizationID: organization.ID, + Title: new("Observer"), Source: store.ProvenanceUser, + }) + require.NoError(err) + + definition := organizationTextDefinition("merge_related_person") + definition.ValueType = store.AttributeValueRecordReference + definition.FieldType = store.AttributeFieldPerson + definition.RecordTarget = new("person") + _, err = st.CreateAttributeDefinitionContext(ctx, definition) + require.NoError(err) + write, err := st.SetOrganizationAttributeValueContext(ctx, store.OrganizationAttributeValueInput{ + OrganizationID: organization.ID, DefinitionSlug: definition.Slug, + Value: store.AttributeValue{ + Type: store.AttributeValueRecordReference, RecordType: new("person"), RecordID: &absorbed.ID, + }, + Source: store.ProvenanceUser, + }) + require.NoError(err) + var observerProjectionBefore int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT vcard_projection_revision + FROM persons WHERE id = ?`), observer.ID).Scan(&observerProjectionBefore)) + + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + result, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-inbound-references", Actor: "test", + }) + require.NoError(err) + + var recordID, currentRowID int64 + var action, provenance string + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT + value.value_record_id, journal.current_row_id, journal.action, journal.provenance_kind + FROM organization_attribute_values value + JOIN person_merge_rows journal + ON journal.table_name = 'organization_attribute_values' + AND journal.original_row_id = value.id + WHERE journal.merge_id = ? AND value.id = ?`), result.Merge.ID, write.Value.ID).Scan( + &recordID, ¤tRowID, &action, &provenance, + )) + assert.Equal(survivor.ID, recordID) + assert.Equal(write.Value.ID, currentRowID) + assert.Equal("repointed", action) + assert.Equal("inbound_reference", provenance) + var observerProjectionAfter int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT vcard_projection_revision + FROM persons WHERE id = ?`), observer.ID).Scan(&observerProjectionAfter)) + assert.Equal(observerProjectionBefore+1, observerProjectionAfter) +} + +func TestMergePersons_IdentityCandidates(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "merge-candidate-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "merge-candidate-absorbed@example.com", "Absorbed") + other := mustPromotedPerson(t, st, "merge-candidate-other@example.com", "Other") + source, err := st.GetOrCreateSource("gmail", "merge-candidates") + require.NoError(err) + input := func(personID int64) store.IdentityMatchCandidateInput { + return store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchPerson, LeftID: personID, + RightKind: store.IdentityMatchPerson, RightID: other.ID, + Basis: store.IdentityMatchDisplayName, NormalizedValue: new("same person"), + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceUser, + SourceID: &source.ID, + } + } + absorbedCandidate, _, err := st.UpsertIdentityMatchCandidateContext(ctx, input(absorbed.ID)) + require.NoError(err) + survivorCandidate, _, err := st.UpsertIdentityMatchCandidateContext(ctx, input(survivor.ID)) + require.NoError(err) + require.Less(absorbedCandidate.ID, survivorCandidate.ID, + "the merge policy must retain survivor provenance rather than the lowest row ID") + evidence, err := st.AddIdentityMatchEvidenceContext(ctx, absorbedCandidate.ID, + store.IdentityMatchEvidenceInput{ + EvidenceKind: "shared_name", Source: store.ProvenanceUser, SourceID: &source.ID, + }) + require.NoError(err) + selfCandidate, _, err := st.UpsertIdentityMatchCandidateContext(ctx, + store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchPerson, LeftID: survivor.ID, + RightKind: store.IdentityMatchPerson, RightID: absorbed.ID, + Basis: store.IdentityMatchDisplayName, State: store.IdentityMatchStateCandidate, + Source: store.ProvenanceUser, SourceID: &source.ID, + }) + require.NoError(err) + selfEvidence, err := st.AddIdentityMatchEvidenceContext(ctx, selfCandidate.ID, + store.IdentityMatchEvidenceInput{ + EvidenceKind: "self-collapse", Source: store.ProvenanceUser, SourceID: &source.ID, + }) + require.NoError(err) + priorRedirectID := absorbedCandidate.ID + 10_000 + _, err = st.DB().ExecContext(ctx, st.Rebind(`INSERT INTO identity_match_candidate_redirects + (retired_candidate_id, surviving_candidate_id, endpoints_collapsed) + VALUES (?, ?, FALSE)`), priorRedirectID, absorbedCandidate.ID) + require.NoError(err) + + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + result, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-identity-candidates", Actor: "test", + }) + require.NoError(err) + + var absorbedRedirect int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT surviving_candidate_id + FROM identity_match_candidate_redirects WHERE retired_candidate_id = ?`), + absorbedCandidate.ID).Scan(&absorbedRedirect)) + assert.Equal(survivorCandidate.ID, absorbedRedirect) + mergedCandidate, err := st.GetIdentityMatchCandidateContext(ctx, absorbedRedirect) + require.NoError(err) + assert.Equal(survivorCandidate.ID, mergedCandidate.ID) + assert.ElementsMatch([]int64{survivor.ID, other.ID}, + []int64{mergedCandidate.LeftID, mergedCandidate.RightID}) + require.Len(mergedCandidate.Evidence, 1) + assert.Equal(evidence.ID, mergedCandidate.Evidence[0].ID) + var redirectedTo int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT surviving_candidate_id + FROM identity_match_candidate_redirects WHERE retired_candidate_id = ?`), + priorRedirectID).Scan(&redirectedTo)) + assert.Equal(survivorCandidate.ID, redirectedTo) + var collapsed bool + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT endpoints_collapsed + FROM identity_match_candidate_redirects WHERE retired_candidate_id = ?`), + selfCandidate.ID).Scan(&collapsed)) + assert.True(collapsed) + + for _, want := range []struct { + id int64 + action string + currentID *int64 + }{ + {id: absorbedCandidate.ID, action: "deduplicated", currentID: &survivorCandidate.ID}, + {id: selfCandidate.ID, action: "deleted_snapshot"}, + } { + var currentID sql.NullInt64 + var action string + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT current_row_id, action + FROM person_merge_rows + WHERE merge_id = ? AND table_name = 'identity_match_candidates' + AND original_row_id = ?`), result.Merge.ID, want.id).Scan(¤tID, &action)) + assert.Equal(want.action, action) + if want.currentID == nil { + assert.False(currentID.Valid) + } else { + require.True(currentID.Valid) + assert.Equal(*want.currentID, currentID.Int64) + } + } + var deletedDependentRows int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM person_merge_rows + WHERE merge_id = ? AND action = 'deleted_snapshot' + AND table_name IN ( + 'identity_match_candidate_sources', + 'identity_match_evidence', + 'identity_match_evidence_sources' + )`), result.Merge.ID).Scan(&deletedDependentRows)) + assert.Equal(3, deletedDependentRows) + var selfEvidenceCount int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM identity_match_evidence WHERE id = ?`), selfEvidence.ID).Scan(&selfEvidenceCount)) + assert.Zero(selfEvidenceCount) +} + +func TestMergePersons_DailyNotes(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "merge-note-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "merge-note-absorbed@example.com", "Absorbed") + shared, err := st.CreateDailyNoteEntryContext(ctx, store.DailyNoteEntryInput{ + LocalDate: "2026-08-19", Body: "shared", Author: "test", + PersonIDs: []int64{survivor.ID, absorbed.ID}, + }) + require.NoError(err) + absorbedOnly, err := st.CreateDailyNoteEntryContext(ctx, store.DailyNoteEntryInput{ + LocalDate: "2026-08-19", Body: "absorbed", Author: "test", + PersonIDs: []int64{absorbed.ID}, + }) + require.NoError(err) + + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + result, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-daily-notes", Actor: "test", + }) + require.NoError(err) + + entries, err := st.ListDailyNoteEntriesForPersonContext(ctx, survivor.ID, "", 0, 0) + require.NoError(err) + require.Len(entries, 2) + assert.Equal([]int64{survivor.ID}, entries[0].PersonIDs) + assert.Equal([]int64{survivor.ID}, entries[1].PersonIDs) + var absorbedRefs int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM daily_note_entry_persons WHERE person_id = ?`), absorbed.ID).Scan(&absorbedRefs)) + assert.Zero(absorbedRefs) + + journalRows, err := st.DB().QueryContext(ctx, st.Rebind(`SELECT + action, current_row_key, provenance_kind + FROM person_merge_rows + WHERE merge_id = ? AND table_name = 'daily_note_entry_persons' + ORDER BY action`), result.Merge.ID) + require.NoError(err) + defer func() { require.NoError(journalRows.Close()) }() + var journalActions []string + for journalRows.Next() { + var action, currentKey, provenance string + require.NoError(journalRows.Scan(&action, ¤tKey, &provenance)) + journalActions = append(journalActions, action) + assert.NotEmpty(currentKey) + assert.Equal("inbound_reference", provenance) + } + require.NoError(journalRows.Err()) + assert.Equal([]string{"deduplicated", "repointed"}, journalActions) + assert.NotEqual(shared.ID, absorbedOnly.ID) +} + +func TestMergePersons_DerivedState(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f, survivor, absorbed, messageID := mergeDerivedStateFixture(t) + ctx := context.Background() + for _, personID := range []int64{survivor.ID, absorbed.ID} { + var count int64 + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT interaction_count + FROM person_contact_state WHERE person_id = ?`), personID).Scan(&count)) + assert.Equal(int64(1), count) + } + + result, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-derived-state", Actor: "test", + }) + require.NoError(err) + + rows, err := f.Store.DB().QueryContext(ctx, f.Store.Rebind(`SELECT person_id, role, evidence + FROM activity_event_persons WHERE message_id = ? ORDER BY person_id`), messageID) + require.NoError(err) + defer func() { require.NoError(rows.Close()) }() + require.True(rows.Next()) + var personID int64 + var role, evidence string + require.NoError(rows.Scan(&personID, &role, &evidence)) + assert.Equal(survivor.ID, personID) + assert.Equal(string(store.RoleAddressed), role) + assert.Equal(string(store.EvidenceDirect), evidence) + assert.False(rows.Next()) + require.NoError(rows.Err()) + + var interactionCount, identityRevision int64 + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT + interaction_count, identity_revision + FROM person_contact_state WHERE person_id = ?`), survivor.ID).Scan( + &interactionCount, &identityRevision, + )) + revisions, err := f.Store.ContactRevisionsContext(ctx) + require.NoError(err) + assert.Equal(int64(3), interactionCount, + "contact state must be rebuilt from three final native events, not summed") + assert.Equal(revisions.IdentityRevision, identityRevision) + var absorbedDerivedRows int + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT + (SELECT COUNT(*) FROM activity_event_persons WHERE person_id = ?) + + (SELECT COUNT(*) FROM person_contact_state WHERE person_id = ?)`), + absorbed.ID, absorbed.ID).Scan(&absorbedDerivedRows)) + assert.Zero(absorbedDerivedRows) + + var journalCount int + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT COUNT(*) + FROM person_merge_rows + WHERE merge_id = ? AND table_name IN ('activity_event_persons', 'person_contact_state')`), + result.Merge.ID).Scan(&journalCount)) + assert.Zero(journalCount, "derived activity is rebuilt from native messages, not snapshotted") +} + +func TestMergePersons_DerivedRollback(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f, survivor, absorbed, messageID := mergeDerivedStateFixture(t) + ctx := context.Background() + if f.Store.IsPostgreSQL() { + _, err := f.Store.DB().ExecContext(ctx, ` + CREATE FUNCTION fail_person_merge_contact_recompute() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'forced person merge contact recompute failure'; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER fail_person_merge_contact_recompute + BEFORE INSERT OR UPDATE ON person_contact_state + FOR EACH ROW EXECUTE FUNCTION fail_person_merge_contact_recompute();`) + require.NoError(err) + } else { + _, err := f.Store.DB().ExecContext(ctx, `CREATE TRIGGER fail_person_merge_contact_recompute + BEFORE INSERT ON person_contact_state BEGIN + SELECT RAISE(ABORT, 'forced person merge contact recompute failure'); + END`) + require.NoError(err) + _, err = f.Store.DB().ExecContext(ctx, `CREATE TRIGGER fail_person_merge_contact_update + BEFORE UPDATE ON person_contact_state BEGIN + SELECT RAISE(ABORT, 'forced person merge contact recompute failure'); + END`) + require.NoError(err) + } + + _, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-derived-rollback", Actor: "test", + }) + require.Error(err) + assert.Contains(err.Error(), "forced person merge contact recompute failure") + for _, person := range []*store.Person{survivor, absorbed} { + got, getErr := f.Store.GetPersonContext(ctx, person.ID) + require.NoError(getErr) + assert.Equal(person.Revision, got.Revision) + } + var mergeCount int + require.NoError(f.Store.DB().QueryRowContext(ctx, `SELECT COUNT(*) + FROM person_merges WHERE idempotency_key = 'merge-derived-rollback'`).Scan(&mergeCount)) + assert.Zero(mergeCount) + var linkCount int + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT COUNT(*) + FROM activity_event_persons WHERE message_id = ?`), messageID).Scan(&linkCount)) + assert.Equal(2, linkCount) +} + +func mergeDerivedStateFixture( + t *testing.T, +) (*storetest.Fixture, *store.Person, *store.Person, int64) { + t.Helper() + require := require.New(t) + f := storetest.New(t) + survivorParticipant := f.EnsureParticipant( + "merge-derived-survivor@example.com", "Survivor", "example.com") + absorbedParticipant := f.EnsureParticipant( + "merge-derived-absorbed@example.com", "Absorbed", "example.com") + survivor, _, err := f.Store.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := f.Store.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + ownerParticipant := f.EnsureParticipant( + "merge-derived-owner@example.com", "Owner", "example.com") + require.NoError(f.Store.AddAccountIdentity( + f.Source.ID, "merge-derived-owner@example.com", "test")) + message := f.NewMessage(). + WithSourceMessageID("merge-derived-state"). + WithSentAt(time.Date(2026, 8, 19, 5, 0, 0, 0, time.UTC)). + WithIsFromMe(true). + Build() + message.SenderID = sql.NullInt64{Int64: ownerParticipant, Valid: true} + messageID, err := f.Store.UpsertMessage(message) + require.NoError(err) + require.NoError(f.Store.ReplaceMessageRecipients( + messageID, "from", []int64{ownerParticipant}, []string{"Owner"})) + require.NoError(f.Store.ReplaceMessageRecipients( + messageID, "to", []int64{survivorParticipant, absorbedParticipant}, + []string{"Survivor", "Absorbed"})) + for label, senderID := range map[string]int64{ + "survivor": survivorParticipant, + "absorbed": absorbedParticipant, + } { + direct := f.NewMessage(). + WithSourceMessageID("merge-derived-direct-" + label). + WithSentAt(time.Date(2026, 8, 19, 6, 0, 0, 0, time.UTC)). + Build() + direct.SenderID = sql.NullInt64{Int64: senderID, Valid: true} + directID, directErr := f.Store.UpsertMessage(direct) + require.NoError(directErr) + require.NoError(f.Store.ReplaceMessageRecipients( + directID, "from", []int64{senderID}, []string{label})) + require.NoError(f.Store.ReplaceMessageRecipients( + directID, "to", []int64{ownerParticipant}, []string{"Owner"})) + } + projector, err := activity.NewProjector(f.Store, activity.Options{ + Timezone: "UTC", BatchSize: 10, MaxDirectCounterparts: 1, + }) + require.NoError(err) + _, err = projector.RunOnce(t.Context()) + require.NoError(err) + survivor, err = f.Store.GetPersonContext(t.Context(), survivor.ID) + require.NoError(err) + absorbed, err = f.Store.GetPersonContext(t.Context(), absorbed.ID) + require.NoError(err) + return f, survivor, absorbed, messageID +} + +func TestMergePersons_ParticipantMergeRejectsCrossOriginLineage(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + survivorParticipant := f.EnsureParticipant("lineage-survivor@example.com", "Survivor", "example.com") + survivorAlias := f.EnsureParticipant( + "lineage-survivor-alias@example.com", "Survivor Alias", "example.com") + absorbedParticipant := f.EnsureParticipant("lineage-absorbed@example.com", "Absorbed", "example.com") + _, err := f.Store.LinkParticipants(survivorParticipant, survivorAlias) + require.NoError(err) + survivor, _, err := f.Store.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := f.Store.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + result, err := f.Store.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-participant-lineage", Actor: "test", + }) + require.NoError(err) + + err = f.Store.MergeParticipants(absorbedParticipant, survivorParticipant) + require.ErrorIs(err, store.ErrPersonMergeLineageConflict) + var lineageCount int + require.NoError(f.Store.DB().QueryRowContext(context.Background(), f.Store.Rebind(`SELECT COUNT(*) + FROM person_merge_participants WHERE merge_id = ?`), + result.Merge.ID).Scan(&lineageCount)) + assert.Equal(3, lineageCount) + var participantCount int + require.NoError(f.Store.DB().QueryRowContext(context.Background(), f.Store.Rebind(`SELECT COUNT(*) + FROM participants WHERE id IN (?, ?)`), absorbedParticipant, survivorParticipant). + Scan(&participantCount)) + assert.Equal(2, participantCount, "the rejected consolidation must roll back") + + split, err := f.Store.SplitPersonMergeContext(context.Background(), store.PersonSplitRequest{ + SourcePersonID: result.Person.ID, MergeID: result.Merge.ID, + ParticipantIDs: []int64{absorbedParticipant}, + ExpectedSourceRevision: result.Person.Revision, + IdempotencyKey: "split-after-rejected-participant-merge", Actor: "test", + }) + require.NoError(err) + assert.True(split.ExactReversal) + assert.ElementsMatch( + []int64{survivorParticipant, survivorAlias}, split.SourcePerson.ParticipantIDs) + assert.Equal([]int64{absorbedParticipant}, split.NewPerson.ParticipantIDs) +} + +func TestMergePersons_ParticipantMergeRejectsDistinctPartialSplitLineage(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + f := storetest.New(t) + survivorParticipant := f.EnsureParticipant( + "split-lineage-survivor@example.com", "Survivor", "example.com") + firstAbsorbed := f.EnsureParticipant( + "split-lineage-first@example.com", "First", "example.com") + secondAbsorbed := f.EnsureParticipant( + "split-lineage-second@example.com", "Second", "example.com") + remainingAbsorbed := f.EnsureParticipant( + "split-lineage-remaining@example.com", "Remaining", "example.com") + _, err := f.Store.LinkParticipants(firstAbsorbed, secondAbsorbed) + require.NoError(err) + _, err = f.Store.LinkParticipants(firstAbsorbed, remainingAbsorbed) + require.NoError(err) + survivor, _, err := f.Store.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := f.Store.CreatePersonFromParticipant(firstAbsorbed) + require.NoError(err) + merged, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-distinct-split-lineage", Actor: "test", + }) + require.NoError(err) + firstSplit, err := f.Store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: []int64{firstAbsorbed}, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "first-partial-lineage", Actor: "test", + }) + require.NoError(err) + assert.False(firstSplit.ExactReversal) + secondSplit, err := f.Store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: firstSplit.SourcePerson.ID, MergeID: merged.Merge.ID, + ParticipantIDs: []int64{secondAbsorbed}, + ExpectedSourceRevision: firstSplit.SourcePerson.Revision, + IdempotencyKey: "second-partial-lineage", Actor: "test", + }) + require.NoError(err) + assert.False(secondSplit.ExactReversal) + assert.NotEqual(firstSplit.Split.ID, secondSplit.Split.ID) + + _, err = f.Store.DB().ExecContext(ctx, f.Store.Rebind( + `DELETE FROM person_participants WHERE participant_id IN (?, ?)`), + firstAbsorbed, secondAbsorbed) + require.NoError(err) + err = f.Store.MergeParticipants(firstAbsorbed, secondAbsorbed) + require.ErrorIs(err, store.ErrPersonMergeLineageConflict) + var lineageCount, participantCount int + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT COUNT(*) + FROM person_merge_participants + WHERE merge_id = ? AND participant_id IN (?, ?) AND split_id IN (?, ?)`), + merged.Merge.ID, firstAbsorbed, secondAbsorbed, + firstSplit.Split.ID, secondSplit.Split.ID).Scan(&lineageCount)) + assert.Equal(2, lineageCount) + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT COUNT(*) + FROM participants WHERE id IN (?, ?)`), firstAbsorbed, secondAbsorbed). + Scan(&participantCount)) + assert.Equal(2, participantCount, "the rejected consolidation must roll back") +} + +func TestMergePersons_DeleteCurrentLineageOwnerIsDomainConflict(t *testing.T) { + require := require.New(t) + f := storetest.New(t) + survivorParticipant := f.EnsureParticipant("delete-lineage-survivor@example.com", "Survivor", "example.com") + absorbedParticipant := f.EnsureParticipant("delete-lineage-absorbed@example.com", "Absorbed", "example.com") + survivor, _, err := f.Store.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := f.Store.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + result, err := f.Store.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-delete-lineage-owner", Actor: "test", + }) + require.NoError(err) + + err = f.Store.DeletePersonContext(context.Background(), result.Person.ID, result.Person.Revision) + require.Error(err) + require.ErrorIs(err, store.ErrPersonMergeActive) + _, getErr := f.Store.GetPersonContext(context.Background(), result.Person.ID) + require.NoError(getErr) +} + +func TestMergePersons_ChainedReplayAndLineageJournal(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + participants := []int64{ + f.EnsureParticipant("chain-a@example.com", "A", "example.com"), + f.EnsureParticipant("chain-b@example.com", "B", "example.com"), + f.EnsureParticipant("chain-c@example.com", "C", "example.com"), + } + people := make([]*store.Person, 0, len(participants)) + var err error + for _, participantID := range participants { + person, _, createErr := f.Store.CreatePersonFromParticipant(participantID) + require.NoError(createErr) + people = append(people, person) + } + for personID, channel := range map[int64]string{people[0].ID: "email", people[1].ID: "chat"} { + _, err = f.Store.SetPersonAttributeValueContext(context.Background(), store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: &channel}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + people[0], err = f.Store.GetPersonContext(context.Background(), people[0].ID) + require.NoError(err) + people[1], err = f.Store.GetPersonContext(context.Background(), people[1].ID) + require.NoError(err) + firstRequest := store.PersonMergeRequest{ + SurvivorID: people[0].ID, AbsorbedID: people[1].ID, + ExpectedSurvivorRevision: people[0].Revision, + ExpectedAbsorbedRevision: people[1].Revision, + IdempotencyKey: "merge-chain-first", Actor: "test", + } + first, err := f.Store.MergePersonsContext(context.Background(), firstRequest) + require.NoError(err) + require.Len(first.ReviewCandidates, 1) + + currentA, err := f.Store.GetPersonContext(context.Background(), people[0].ID) + require.NoError(err) + currentC, err := f.Store.GetPersonContext(context.Background(), people[2].ID) + require.NoError(err) + second, err := f.Store.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: currentC.ID, AbsorbedID: currentA.ID, + ExpectedSurvivorRevision: currentC.Revision, + ExpectedAbsorbedRevision: currentA.Revision, + IdempotencyKey: "merge-chain-second", Actor: "test", + }) + require.NoError(err) + + for _, want := range []struct { + table string + rowID int64 + }{ + {table: "person_merges", rowID: first.Merge.ID}, + {table: "person_merge_review_candidates", rowID: first.ReviewCandidates[0].ID}, + } { + var action, key string + require.NoError(f.Store.DB().QueryRowContext(context.Background(), f.Store.Rebind(`SELECT action, original_row_key + FROM person_merge_rows + WHERE merge_id = ? AND table_name = ? AND original_row_id = ?`), + second.Merge.ID, want.table, want.rowID).Scan(&action, &key)) + assert.Equal("repointed", action) + assert.NotEmpty(key) + } + + replayed, err := f.Store.MergePersonsContext(context.Background(), firstRequest) + require.NoError(err) + assertJSONEquivalent(t, first, replayed, + "idempotency must replay the original committed response") +} + +func TestMergePersons_Rollback(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewSQLiteTestStore(t) + survivorParticipant, err := st.EnsureParticipant("rollback-survivor@example.com", "Survivor", "example.com") + require.NoError(err) + absorbedParticipant, err := st.EnsureParticipant("rollback-absorbed@example.com", "Absorbed", "example.com") + require.NoError(err) + survivor, _, err := st.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := st.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + _, err = st.AddPersonNameContext(context.Background(), absorbed.ID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Must Survive Rollback"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + absorbed, err = st.GetPersonContext(context.Background(), absorbed.ID) + require.NoError(err) + _, err = st.DB().Exec(`CREATE TRIGGER fail_person_merge_alias + BEFORE INSERT ON person_uid_aliases BEGIN + SELECT RAISE(ABORT, 'forced merge rollback'); + END`) + require.NoError(err) + + _, err = st.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-rollback", Actor: "test", + }) + require.Error(err) + assert.Contains(err.Error(), "forced merge rollback") + + unchangedSurvivor, err := st.GetPersonContext(context.Background(), survivor.ID) + require.NoError(err) + assert.Equal([]int64{survivorParticipant}, unchangedSurvivor.ParticipantIDs) + unchangedAbsorbed, err := st.GetPersonContext(context.Background(), absorbed.ID) + require.NoError(err) + assert.Equal([]int64{absorbedParticipant}, unchangedAbsorbed.ParticipantIDs) + profile, err := st.GetPersonProfileContext(context.Background(), absorbed.ID) + require.NoError(err) + assert.Len(profile.Names, 1) + var mergeCount int + require.NoError(st.DB().QueryRow( + `SELECT COUNT(*) FROM person_merges WHERE idempotency_key = 'merge-rollback'`, + ).Scan(&mergeCount)) + assert.Zero(mergeCount) +} + +func TestPersonMergeRollbackStages(t *testing.T) { + stages := []struct { + name, event, table, prepare string + }{ + {name: "after snapshot insertion", event: "INSERT", table: "person_merge_participants"}, + {name: "midway row policies", event: "UPDATE", table: "person_names", prepare: "name"}, + {name: "alias retargeting", event: "UPDATE", table: "person_uid_aliases", prepare: "alias"}, + {name: "after root deletion", event: "INSERT", table: "person_uid_aliases"}, + } + for index, stage := range stages { + t.Run(stage.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + key := fmt.Sprintf("rollback-stage-%d", index) + survivor := mustPromotedPerson(t, st, key+"-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, key+"-absorbed@example.com", "Absorbed") + switch stage.prepare { + case "name": + _, err := st.AddPersonNameContext(ctx, absorbed.ID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Rollback Name"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + case "alias": + _, err := st.RetirePersonUIDAliasContext( + ctx, key+"-retired-uid", &absorbed.ID, "test") + require.NoError(err) + } + var err error + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + installPersonMergeFailureTrigger(t, st, index, stage.event, stage.table) + + _, err = st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: key, Actor: "test", + }) + require.Error(err) + assert.Contains(err.Error(), "forced merge rollback stage") + for _, before := range []*store.Person{survivor, absorbed} { + after, getErr := st.GetPersonContext(ctx, before.ID) + require.NoError(getErr) + assert.Equal(before.Revision, after.Revision) + assert.Equal(before.ParticipantIDs, after.ParticipantIDs) + } + assertPersonMergeConcurrencyState(t, st, 0) + }) + } +} + +func installPersonMergeFailureTrigger( + t *testing.T, st *store.Store, index int, event, table string, +) { + t.Helper() + triggerName := fmt.Sprintf("fail_person_merge_stage_%d", index) + if st.IsPostgreSQL() { + functionName := triggerName + "_fn" + _, err := st.DB().ExecContext(context.Background(), fmt.Sprintf(` + CREATE FUNCTION %s() RETURNS trigger AS $$ + BEGIN RAISE EXCEPTION 'forced merge rollback stage'; END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER %s BEFORE %s ON %s + FOR EACH ROW EXECUTE FUNCTION %s();`, + functionName, triggerName, event, table, functionName)) + require.NoError(t, err) + return + } + _, err := st.DB().ExecContext(context.Background(), fmt.Sprintf(` + CREATE TRIGGER %s BEFORE %s ON %s BEGIN + SELECT RAISE(ABORT, 'forced merge rollback stage'); + END`, triggerName, event, table)) + require.NoError(t, err) +} + +func TestMergePersons_ErrorHygiene(t *testing.T) { + t.Run("participant IDs", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewSQLiteTestStore(t) + _, err := st.EnsureParticipant("error-participant-dummy@example.com", "Dummy", "example.com") + require.NoError(err) + survivorParticipant, err := st.EnsureParticipant( + "error-participant-survivor@example.com", "Survivor", "example.com", + ) + require.NoError(err) + absorbedParticipant, err := st.EnsureParticipant( + "error-participant-absorbed@example.com", "Absorbed", "example.com", + ) + require.NoError(err) + survivor, _, err := st.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := st.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + _, err = st.DB().Exec(`CREATE TRIGGER fail_person_merge_participant + BEFORE INSERT ON person_merge_participants BEGIN + SELECT RAISE(ABORT, 'participant journal failure'); + END`) + require.NoError(err) + + _, err = st.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-error-participant", Actor: "test", + }) + require.Error(err) + assert.NotContains(err.Error(), strconv.FormatInt(survivorParticipant, 10)) + assert.NotContains(err.Error(), strconv.FormatInt(absorbedParticipant, 10)) + }) + + t.Run("serialized row keys", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewSQLiteTestStore(t) + survivorParticipant, err := st.EnsureParticipant( + "error-row-survivor@example.com", "Survivor", "example.com", + ) + require.NoError(err) + absorbedParticipant, err := st.EnsureParticipant( + "error-row-absorbed@example.com", "Absorbed", "example.com", + ) + require.NoError(err) + survivor, _, err := st.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := st.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + privateUID := "private-retired-person-uid" + _, err = st.RetirePersonUIDAliasContext( + context.Background(), privateUID, &absorbed.ID, "test", + ) + require.NoError(err) + absorbed, err = st.GetPersonContext(context.Background(), absorbed.ID) + require.NoError(err) + _, err = st.DB().Exec(`CREATE TRIGGER fail_person_merge_row + BEFORE INSERT ON person_merge_rows BEGIN + SELECT RAISE(ABORT, 'row journal failure'); + END`) + require.NoError(err) + + _, err = st.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "merge-error-row", Actor: "test", + }) + require.Error(err) + assert.NotContains(err.Error(), privateUID) + }) +} + +func TestPersonMergeConcurrencyMergeMerge(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st, survivor, absorbed := newPersonMergeConcurrencyFixture(t, "merge-merge") + release := personOperationContentionBarrier(t, st, 2) + results := make(chan error, 2) + for _, key := range []string{"concurrent-merge-a", "concurrent-merge-b"} { + go func() { + _, err := st.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: key, Actor: "test", + }) + results <- err + }() + } + release() + errs := []error{<-results, <-results} + assert.Equal(1, countNilErrors(errs), "exactly one conflicting merge may commit") + for _, err := range errs { + if err != nil { + assert.True(errors.Is(err, store.ErrPersonRevisionConflict) || + errors.Is(err, store.ErrPersonNotFound), + "loser must report a typed stale-person error: %v", err) + } + } + current, err := st.GetPersonContext(context.Background(), survivor.ID) + require.NoError(err) + assert.Equal(survivor.Revision+1, current.Revision) + assertPersonMergeConcurrencyState(t, st, 1) +} + +func personOperationContentionBarrier( + t *testing.T, st *store.Store, operations int, +) func() { + t.Helper() + arrived := make(chan struct{}, operations) + gate := make(chan struct{}) + restore := st.SetPersonOperationBeforeIdentityLockHookForTest(func() { + select { + case arrived <- struct{}{}: + case <-gate: + return + } + <-gate + }) + t.Cleanup(restore) + return func() { + t.Helper() + timer := time.NewTimer(10 * time.Second) + defer timer.Stop() + for range operations { + select { + case <-arrived: + case <-timer.C: + close(gate) + require.FailNow(t, "person-operation contention barrier was not reached") + } + } + close(gate) + } +} + +func TestPersonMergeConcurrencyProfileUpdate(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st, survivor, absorbed := newPersonMergeConcurrencyFixture(t, "merge-profile") + start := make(chan struct{}) + results := make(chan error, 2) + go func() { + <-start + _, err := st.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "concurrent-merge-profile", Actor: "test", + }) + results <- err + }() + go func() { + <-start + _, err := st.UpdatePersonDisplayNameContext( + context.Background(), survivor.ID, survivor.Revision, new("Concurrent Name")) + results <- err + }() + close(start) + errs := []error{<-results, <-results} + assert.Equal(1, countNilErrors(errs), "merge and stale profile update cannot both commit") + current, err := st.GetPersonContext(context.Background(), survivor.ID) + require.NoError(err) + assert.Equal(survivor.Revision+1, current.Revision) + var mergeCount int + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM person_merges`).Scan(&mergeCount)) + assert.Contains([]int{0, 1}, mergeCount) + assertPersonMergeConcurrencyState(t, st, mergeCount) +} + +func TestPersonMergeConcurrencyIdentityLink(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st, survivor, absorbed := newPersonMergeConcurrencyFixture(t, "merge-link") + left, right := survivor.ParticipantIDs[0], absorbed.ParticipantIDs[0] + start := make(chan struct{}) + mergeDone := make(chan error, 1) + linkDone := make(chan error, 1) + go func() { + <-start + _, err := st.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "concurrent-merge-link", Actor: "test", + }) + mergeDone <- err + }() + go func() { + <-start + _, err := st.LinkParticipants(left, right) + linkDone <- err + }() + close(start) + require.NoError(<-mergeDone) + linkErr := <-linkDone + if linkErr != nil { + require.ErrorIs(linkErr, store.ErrPersonBindingConflict) + } + current, err := st.GetPersonContext(context.Background(), survivor.ID) + require.NoError(err) + assert.Equal(survivor.Revision+1, current.Revision) + assertPersonMergeConcurrencyState(t, st, 1) +} + +func newPersonMergeConcurrencyFixture( + t *testing.T, key string, +) (*store.Store, *store.Person, *store.Person) { + t.Helper() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, key+"-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, key+"-absorbed@example.com", "Absorbed") + return st, survivor, absorbed +} + +func countNilErrors(errs []error) int { + count := 0 + for _, err := range errs { + if err == nil { + count++ + } + } + return count +} + +func assertPersonMergeConcurrencyState(t *testing.T, st *store.Store, wantMerges int) { + t.Helper() + require := require.New(t) + assert := assert.New(t) + var mergeCount, orphanRows, orphanParticipants int + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM person_merges`).Scan(&mergeCount)) + assert.Equal(wantMerges, mergeCount) + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM person_merge_rows row_record + LEFT JOIN person_merges merge_record ON merge_record.id = row_record.merge_id + WHERE merge_record.id IS NULL`).Scan(&orphanRows)) + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM person_merge_participants lineage + LEFT JOIN person_merges merge_record ON merge_record.id = lineage.merge_id + WHERE merge_record.id IS NULL`).Scan(&orphanParticipants)) + assert.Zero(orphanRows) + assert.Zero(orphanParticipants) + assertSQLiteForeignKeysClean(t, st) +} + +func assertSQLiteForeignKeysClean(t *testing.T, st *store.Store) { + t.Helper() + if st.IsPostgreSQL() { + return + } + rows, err := st.DB().Query(`PRAGMA foreign_key_check`) + require.NoError(t, err) + defer func() { require.NoError(t, rows.Close()) }() + assert.False(t, rows.Next(), "foreign_key_check must return no violations") + require.NoError(t, rows.Err()) +} + +var personMergeTableColumns = map[string][]string{ + "person_merges": { + "absorbed_person_id", "absorbed_revision_before", "absorbed_uid", "actor", + "created_at", "current_person_id", "id", "idempotency_key", "identity_revision", "request_hash", + "result_json", "snapshot_blob", "snapshot_sha256", "snapshot_version", + "survivor_person_id_at_merge", "survivor_revision_after", + "survivor_revision_before", "survivor_uid", + }, + "person_splits": { + "actor", "created_at", "id", "idempotency_key", "identity_revision", "is_exact_reversal", + "merge_id", "new_person_id", "new_person_uid", "request_hash", + "result_json", "source_person_id", "source_revision_after", "source_revision_before", + }, + "person_merge_participants": { + "merge_id", "origin_side", "participant_id", "split_id", + }, + "person_merge_rows": { + "action", "current_row_id", "current_row_key", "merge_id", "original_row_id", + "original_row_key", "origin_side", "participant_id", "provenance_kind", + "post_merge_row_json", "snapshot_path", "split_id", "table_name", + }, + "person_merge_row_person_refs": { + "column_name", "merge_id", "original_row_key", "person_id", "table_name", + }, + "person_merge_review_candidates": { + "absorbed_value_id", "created_at", "definition_id", "id", "merge_id", + "resolution_value_id", "reviewed_at", "reviewed_by", "state", + "survivor_person_id", "survivor_value_id", + }, +} + +func TestPersonMergeSchema(t *testing.T) { + st := testutil.NewSQLiteTestStore(t) + + assertPersonMergeSchema(t, st) +} + +func TestPostgresPersonMergeSchema(t *testing.T) { + st := testutil.NewTestStore(t) + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL-only person merge schema assertion") + } + + assertPersonMergeSchema(t, st) + var currentRowIDType string + require.NoError(t, st.DB().QueryRowContext(context.Background(), `SELECT data_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'person_merge_rows' + AND column_name = 'current_row_id'`).Scan(¤tRowIDType)) + assert.Equal(t, "bigint", currentRowIDType) +} + +func TestPostgresMergePersonsReconcilesSingleAttributes(t *testing.T) { + require := require.New(t) + st := testutil.NewTestStore(t) + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL-only merge lock assertion") + } + ctx := context.Background() + survivorParticipant, err := st.EnsureParticipant( + "pg-merge-survivor@example.com", "Survivor", "example.com", + ) + require.NoError(err) + absorbedParticipant, err := st.EnsureParticipant( + "pg-merge-absorbed@example.com", "Absorbed", "example.com", + ) + require.NoError(err) + survivor, _, err := st.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := st.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + for personID, value := range map[int64]string{survivor.ID: "email", absorbed.ID: "chat"} { + _, err = st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: &value}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + jsonDefinition := personTextDefinition("postgres_merge_json_equivalence") + jsonDefinition.UniversalID = "test-postgres-merge-json-equivalence" + jsonDefinition.ValueType = store.AttributeValueJSON + jsonDefinition.FieldType = store.AttributeFieldJSON + _, err = st.CreateAttributeDefinitionContext(ctx, jsonDefinition) + require.NoError(err) + for personID, value := range map[int64]json.RawMessage{ + survivor.ID: json.RawMessage(`{"a":1,"b":2}`), + absorbed.ID: json.RawMessage(`{"b":2,"a":1}`), + } { + _, err = st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: jsonDefinition.Slug, + Value: store.AttributeValue{Type: store.AttributeValueJSON, JSON: value}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + + result, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "postgres-single-attribute-merge", Actor: "test", + }) + require.NoError(err) + assert.Len(t, result.ReviewCandidates, 1) +} + +func TestPostgresMergePersonsFencesConcurrentEnvelopeWriter(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL merge/envelope interleaving regression") + } + ctx := t.Context() + survivorParticipant, err := st.EnsureParticipant( + "pg-envelope-merge-survivor@example.com", "Survivor", "example.com", + ) + require.NoError(err) + absorbedParticipant, err := st.EnsureParticipant( + "pg-envelope-merge-absorbed@example.com", "Absorbed", "example.com", + ) + require.NoError(err) + survivor, _, err := st.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := st.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + raw := []byte("BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Absorbed\r\nEND:VCARD\r\n") + envelope := parseStoreEnvelope(t, raw, "pg-merge-book", "pg-merge-envelope") + envelope.CanonicalPersonUID = absorbed.VCardUID + stored, err := st.PutVCardResourceEnvelopeContext(ctx, store.VCardResourceEnvelopeInput{ + PersonID: absorbed.ID, Envelope: envelope, + }) + require.NoError(err) + replacement := replaceStoreFormattedName(t, stored.ResourceEnvelope, "Stale Replacement") + + gate := openPostgreSQLUpdateGate(ctx, t, st, 638901, + "vcard_resource_envelopes", stored.ID, "wait_for_person_merge_envelope_move") + type mergeOutcome struct { + result *store.PersonMergeResult + err error + } + mergeDone := make(chan mergeOutcome, 1) + gate.run(func() { + result, mergeErr := st.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "postgres-envelope-fence", Actor: "test", + }) + mergeDone <- mergeOutcome{result: result, err: mergeErr} + }) + mergePID := waitForPostgreSQLBlockedPID(ctx, t, st, gate.holderPID, + "UPDATE vcard_resource_envelopes", "merge did not reach the envelope ownership move") + + writerDone := make(chan error, 1) + gate.run(func() { + expected := stored.Revision + _, writeErr := st.PutVCardResourceEnvelopeContext(context.Background(), store.VCardResourceEnvelopeInput{ + PersonID: absorbed.ID, ExpectedRevision: &expected, Envelope: replacement, + }) + writerDone <- writeErr + }) + require.True(waitForPostgreSQLBlockedBy(ctx, t, st, mergePID, + "UPDATE vcard_resource_envelopes"), "stale writer did not wait behind merge fence") + + gate.release() + merged := <-mergeDone + require.NoError(merged.err) + require.NotNil(merged.result) + require.ErrorIs(<-writerDone, store.ErrVCardResourceWriteConflict) + moved, err := st.GetVCardResourceEnvelopeContext(ctx, "pg-merge-book", "pg-merge-envelope") + require.NoError(err) + assert.Equal(survivor.ID, moved.PersonID) + assert.Equal(stored.Revision+1, moved.Revision) +} + +func TestPostgresMergePersonsFencesConcurrentReferenceSupersede(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := testutil.NewTestStore(t) + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL merge/reference supersede interleaving regression") + } + ctx := t.Context() + survivor := mustPromotedPerson(t, st, + "pg-reference-merge-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, + "pg-reference-merge-absorbed@example.com", "Absorbed") + observer := mustPromotedPerson(t, st, + "pg-reference-merge-observer@example.com", "Observer") + definition := personTextDefinition("pg_merge_reference_supersede") + definition.ValueType = store.AttributeValueRecordReference + definition.FieldType = store.AttributeFieldPerson + definition.RecordTarget = new("person") + _, err := st.CreateAttributeDefinitionContext(ctx, definition) + require.NoError(err) + write, err := st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: observer.ID, DefinitionSlug: definition.Slug, + Value: store.AttributeValue{ + Type: store.AttributeValueRecordReference, RecordType: new("person"), + RecordID: &absorbed.ID, + }, + Source: store.ProvenanceUser, + }) + require.NoError(err) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + + snapshotCaptured := make(chan struct{}, 1) + releaseMerge := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseMerge) }) } + t.Cleanup(release) + restoreHook := st.SetPersonMergeAfterSnapshotHookForTest(func() { + select { + case snapshotCaptured <- struct{}{}: + default: + } + <-releaseMerge + }) + t.Cleanup(restoreHook) + type mergeOutcome struct { + result *store.PersonMergeResult + err error + } + mergeDone := make(chan mergeOutcome, 1) + go func() { + result, mergeErr := st.MergePersonsContext(context.Background(), store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "postgres-reference-supersede-merge", Actor: "test", + }) + mergeDone <- mergeOutcome{result: result, err: mergeErr} + }() + select { + case <-snapshotCaptured: + case <-time.After(10 * time.Second): + release() + require.FailNow("merge did not pause after capturing its reversal snapshot") + } + + type supersedeOutcome struct { + write *store.PersonAttributeWrite + err error + } + supersedeDone := make(chan supersedeOutcome, 1) + go func() { + result, supersedeErr := st.SupersedePersonAttributeValueContext( + context.Background(), store.PersonAttributeSupersedeInput{ + PersonID: observer.ID, DefinitionSlug: definition.Slug, + ExpectedValueID: &write.Value.ID, + }) + supersedeDone <- supersedeOutcome{write: result, err: supersedeErr} + }() + select { + case early := <-supersedeDone: + release() + require.FailNow("reference supersede did not wait for merge identity lock", + "result=%v err=%v", early.write, early.err) + case <-time.After(500 * time.Millisecond): + } + release() + merged := <-mergeDone + require.NoError(merged.err) + require.NotNil(merged.result) + superseded := <-supersedeDone + require.NoError(superseded.err) + require.NotNil(superseded.write) + + current, err := st.GetPersonContext(ctx, merged.result.Person.ID) + require.NoError(err) + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: merged.result.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "postgres-reference-supersede-split", Actor: "test", + }) + require.NoError(err) + values, err := st.ListPersonAttributeValuesContext(ctx, observer.ID, + store.PersonAttributeQuery{DefinitionSlug: definition.Slug, IncludeHistory: true}) + require.NoError(err) + require.Len(values, 1) + require.NotNil(values[0].Value.RecordID) + assert.Equal(split.NewPerson.ID, *values[0].Value.RecordID) + assert.NotNil(values[0].ActiveUntil) + assert.NotNil(values[0].SupersededAt) +} + +func assertPersonMergeSchema(t *testing.T, st *store.Store) { + t.Helper() + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + for table, want := range personMergeTableColumns { + got := func() []string { + rows, err := st.DB().QueryContext(ctx, "SELECT * FROM "+table+" WHERE 1 = 0") + require.NoError(err, "query %s", table) + defer func() { require.NoError(rows.Close(), "close %s column query", table) }() + columns, err := rows.Columns() + require.NoError(err, "read %s columns", table) + require.NoError(rows.Err(), "iterate %s column query", table) + return columns + }() + sort.Strings(got) + sort.Strings(want) + assert.Equal(want, got, "columns for %s", table) + } + + assertForeignKeyTarget(t, st, "person_merges", "current_person_id", "persons") + assertForeignKeyTarget(t, st, "person_splits", "merge_id", "person_merges") + assertForeignKeyTarget(t, st, "person_merge_participants", "participant_id", "participants") + assertForeignKeyTarget(t, st, "person_merge_participants", "split_id", "person_splits") + assertForeignKeyTarget(t, st, "person_merge_rows", "split_id", "person_splits") + assertForeignKeyTarget(t, st, "person_merge_review_candidates", "definition_id", "attribute_definitions") +} + +func assertForeignKeyTarget(t *testing.T, st *store.Store, table, column, target string) { + t.Helper() + query := `SELECT "table" FROM pragma_foreign_key_list(?) WHERE "from" = ?` + args := []any{table, column} + if st.IsPostgreSQL() { + query = `SELECT ccu.table_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.constraint_schema = kcu.constraint_schema + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + AND tc.constraint_schema = ccu.constraint_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = current_schema() + AND tc.table_name = ? + AND kcu.column_name = ?` + } + var got string + require.NoError(t, st.DB().QueryRowContext(context.Background(), st.Rebind(query), args...).Scan(&got), + "foreign key %s.%s", table, column) + assert.Equal(t, target, got, "foreign key target for %s.%s", table, column) +} diff --git a/internal/store/person_splits.go b/internal/store/person_splits.go new file mode 100644 index 000000000..3b0855a08 --- /dev/null +++ b/internal/store/person_splits.go @@ -0,0 +1,2307 @@ +package store + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "reflect" + "slices" + "strconv" + "strings" +) + +const ( + personSplitAliasRetargeted = "retired_uid_alias_retargeted" + personSplitAliasUnchanged = "retired_uid_alias_unchanged" +) + +type personSplitLineage struct { + participantID int64 + originSide personMergeOriginSide + splitID sql.NullInt64 + personID sql.NullInt64 +} + +type personSplitLineageSelection struct { + exact bool + restoresAbsorbed bool +} + +type personSplitAncestorRestoration struct { + merge *PersonMerge + snapshot personMergeSnapshot + absorbedRoot personMergeSnapshotPerson + participantIDs []int64 + selection personSplitLineageSelection +} + +type personSplitJournalRow struct { + tableName string + originalRowID sql.NullInt64 + originalKey string + currentRowID sql.NullInt64 + currentKey sql.NullString + provenance personMergeProvenanceKind + originSide personMergeOriginSide + participantID sql.NullInt64 + action string + snapshotPath string + postMergeJSON sql.NullString +} + +// SplitPersonMergeContext restores a merged profile into a fresh person. +// Selected absorbed-origin participant lineages move with it; an empty +// selection reverses a merge whose absorbed profile had no participants. +// Aggregate profile rows are restored when the selection completes their +// owning merge; a partial split otherwise moves only participant-exact +// evidence and reports the rows left behind. +func (s *Store) SplitPersonMergeContext( + ctx context.Context, request PersonSplitRequest, +) (*PersonSplitResult, error) { + request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey) + request.Actor = strings.TrimSpace(request.Actor) + if err := request.validate(); err != nil { + return nil, err + } + request.ParticipantIDs = request.canonicalParticipantIDs() + return retryBusyWrite(ctx, s, "split person merge", func() (*PersonSplitResult, error) { + return s.splitPersonMergeOnce(ctx, request) + }) +} + +func (s *Store) splitPersonMergeOnce( + ctx context.Context, request PersonSplitRequest, +) (*PersonSplitResult, error) { + requestHash, err := personSplitRequestHash(request) + if err != nil { + return nil, err + } + var result *PersonSplitResult + err = s.withTxContext(ctx, func(tx *loggedTx) error { + if s.personOperationBeforeIdentityLockHook != nil { + s.personOperationBeforeIdentityLockHook() + } + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return err + } + replayed, found, err := s.personSplitByIdempotencyKeyTx( + ctx, tx, request.IdempotencyKey, requestHash, + ) + if err != nil { + return err + } + if found { + result = replayed + return nil + } + + var sourceRevision int64 + var sourceUID string + err = tx.QueryRowContext(ctx, `SELECT revision, vcard_uid FROM persons WHERE id = ?`+ + s.dialect.SelectForUpdate(), request.SourcePersonID).Scan(&sourceRevision, &sourceUID) + if errors.Is(err, sql.ErrNoRows) { + return ErrPersonNotFound + } + if err != nil { + return fmt.Errorf("lock split source person: %w", err) + } + if sourceRevision != request.ExpectedSourceRevision { + return ErrPersonSplitRevision + } + + merge, snapshot, err := s.loadPersonSplitMergeTx(ctx, tx, request.MergeID) + if err != nil { + return err + } + if merge.CurrentPersonID == nil { + var splitExists bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM person_splits WHERE merge_id = ? + )`, request.MergeID).Scan(&splitExists); err != nil { + return fmt.Errorf("inspect completed person split: %w", err) + } + if splitExists { + return ErrPersonMergeAlreadySplit + } + return ErrPersonSplitOwnership + } + if *merge.CurrentPersonID != request.SourcePersonID { + return ErrPersonSplitOwnership + } + lineage, err := s.loadPersonSplitLineageTx(ctx, tx, request.MergeID) + if err != nil { + return err + } + selection, err := validatePersonSplitLineage( + lineage, request.SourcePersonID, request.ParticipantIDs, + ) + if err != nil { + return err + } + if selection.restoresAbsorbed { + reviewed, err := personSplitHasPostMergeAcceptedCandidateTx( + ctx, tx, request.MergeID, snapshot, + ) + if err != nil { + return err + } + if reviewed { + return ErrPersonSplitReviewed + } + } + ancestorRestorations, transferredMergeIDs, err := + s.loadPersonSplitAncestorRestorationsTx(ctx, tx, request) + if err != nil { + return err + } + + absorbedRoot, err := absorbedPersonMergeSnapshotRoot(snapshot) + if err != nil { + return err + } + newUID, err := newVCardUID() + if err != nil { + return err + } + var displayName any + if selection.restoresAbsorbed && absorbedRoot.DisplayName != nil { + displayName = *absorbedRoot.DisplayName + } + var newPersonID int64 + if err := tx.QueryRowContext(ctx, + `INSERT INTO persons (vcard_uid, display_name) VALUES (?, ?) RETURNING id`, + newUID, displayName, + ).Scan(&newPersonID); err != nil { + return fmt.Errorf("create split person: %w", err) + } + var splitID int64 + if err := tx.QueryRowContext(ctx, `INSERT INTO person_splits ( + merge_id, idempotency_key, request_hash, source_person_id, new_person_id, + new_person_uid, source_revision_before, source_revision_after, actor, + is_exact_reversal + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + request.MergeID, request.IdempotencyKey, requestHash, + request.SourcePersonID, newPersonID, newUID, + sourceRevision, sourceRevision+1, request.Actor, false, + ).Scan(&splitID); err != nil { + if s.dialect.IsConflictError(err) { + return ErrPersonSplitIdempotency + } + return fmt.Errorf("insert person split: %w", err) + } + + if len(request.ParticipantIDs) > 0 { + if err := s.deletePersonSplitCrossingLinksTx(ctx, tx, request.ParticipantIDs); err != nil { + return err + } + args := []any{newPersonID, request.SourcePersonID} + args = append(args, personMergeSnapshotIDArgs(request.ParticipantIDs)...) + bindingResult, err := tx.ExecContext(ctx, `UPDATE person_participants + SET person_id = ? WHERE person_id = ? AND participant_id IN (`+ + personMergeSnapshotPlaceholders(len(request.ParticipantIDs))+`)`, args...) + if err != nil { + return fmt.Errorf("move split participant bindings: %w", err) + } + if moved, err := bindingResult.RowsAffected(); err != nil { + return fmt.Errorf("count split participant bindings: %w", err) + } else if moved != int64(len(request.ParticipantIDs)) { + return fmt.Errorf("%w: participant binding changed during split", ErrPersonSplitParticipants) + } + } + + unrestored := []PersonMergeRowRef{} + ambiguous, err := s.restorePersonSplitRowsTx( + ctx, tx, request.MergeID, splitID, request.SourcePersonID, + newPersonID, snapshot.Persons[0].ID, absorbedRoot.ID, sourceUID, newUID, + request.ParticipantIDs, selection, snapshot, &unrestored, + ) + if err != nil { + return err + } + for _, ancestor := range ancestorRestorations { + ancestorAmbiguous, err := s.restorePersonSplitRowsTx( + ctx, tx, ancestor.merge.ID, splitID, request.SourcePersonID, + newPersonID, ancestor.snapshot.Persons[0].ID, ancestor.absorbedRoot.ID, + sourceUID, newUID, ancestor.participantIDs, ancestor.selection, ancestor.snapshot, + &unrestored, + ) + if err != nil { + return err + } + ambiguous = append(ambiguous, ancestorAmbiguous...) + if ancestor.absorbedRoot.DisplayName != nil { + if _, err := tx.ExecContext(ctx, `UPDATE persons + SET display_name = COALESCE(display_name, ?) WHERE id = ?`, + *ancestor.absorbedRoot.DisplayName, newPersonID); err != nil { + return fmt.Errorf("restore ancestor split display name: %w", err) + } + } + if _, err := tx.ExecContext(ctx, `UPDATE person_merge_review_candidates SET + state = 'rejected', reviewed_by = ?, reviewed_at = `+s.dialect.Now()+` + WHERE merge_id = ? AND state = 'pending'`, request.Actor, ancestor.merge.ID); err != nil { + return fmt.Errorf("finalize ancestor-split review candidates: %w", err) + } + aliasResult, err := tx.ExecContext(ctx, `UPDATE person_uid_aliases + SET surviving_person_id = ? WHERE retired_uid = ? AND surviving_person_id = ?`, + newPersonID, ancestor.merge.AbsorbedVCardUID, request.SourcePersonID) + if err != nil { + return fmt.Errorf("retarget ancestor split retired UID alias: %w", err) + } + if changed, err := aliasResult.RowsAffected(); err != nil { + return fmt.Errorf("count ancestor split retired UID alias: %w", err) + } else if changed != 1 { + return fmt.Errorf("%w: ancestor absorbed UID alias is not owned by source", + ErrPersonSplitParticipants) + } + } + if selection.restoresAbsorbed { + if _, err := tx.ExecContext(ctx, `UPDATE person_merge_review_candidates SET + state = 'rejected', reviewed_by = ?, reviewed_at = `+s.dialect.Now()+` + WHERE merge_id = ? AND state = 'pending'`, request.Actor, request.MergeID); err != nil { + return fmt.Errorf("finalize exact-split review candidates: %w", err) + } + } + if err := s.reconcilePersonSplitCounterpartProjectionsTx( + ctx, tx, splitID, request.SourcePersonID, newPersonID, + ); err != nil { + return err + } + aliasDisposition := personSplitAliasUnchanged + if selection.restoresAbsorbed { + aliasResult, err := tx.ExecContext(ctx, `UPDATE person_uid_aliases + SET surviving_person_id = ? WHERE retired_uid = ? AND surviving_person_id = ?`, + newPersonID, merge.AbsorbedVCardUID, request.SourcePersonID) + if err != nil { + return fmt.Errorf("retarget split retired UID alias: %w", err) + } + if changed, err := aliasResult.RowsAffected(); err != nil { + return fmt.Errorf("count split retired UID alias: %w", err) + } else if changed != 1 { + return fmt.Errorf("%w: absorbed UID alias is not owned by source", ErrPersonSplitParticipants) + } + aliasDisposition = personSplitAliasRetargeted + } + for _, mergeID := range transferredMergeIDs { + if _, err := tx.ExecContext(ctx, `UPDATE person_merges + SET current_person_id = ? WHERE id = ? AND current_person_id = ?`, + newPersonID, mergeID, request.SourcePersonID); err != nil { + return fmt.Errorf("transfer nested merge lineage: %w", err) + } + } + + if len(request.ParticipantIDs) == 0 { + if _, err := tx.ExecContext(ctx, `UPDATE person_merges SET current_person_id = NULL + WHERE id = ? AND current_person_id = ?`, request.MergeID, request.SourcePersonID); err != nil { + return fmt.Errorf("close root-only merge lineage: %w", err) + } + } else { + lineageArgs := []any{splitID, request.SourcePersonID} + lineageArgs = append(lineageArgs, personMergeSnapshotIDArgs(request.ParticipantIDs)...) + if _, err := tx.ExecContext(ctx, `UPDATE person_merge_participants SET split_id = ? + WHERE split_id IS NULL AND merge_id IN ( + SELECT id FROM person_merges WHERE current_person_id = ? + ) AND participant_id IN (`+ + personMergeSnapshotPlaceholders(len(request.ParticipantIDs))+`)`, lineageArgs...); err != nil { + return fmt.Errorf("mark split participant lineage: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE person_merges + SET current_person_id = NULL + WHERE current_person_id = ? AND NOT EXISTS ( + SELECT 1 FROM person_merge_participants lineage + WHERE lineage.merge_id = person_merges.id + AND lineage.origin_side = 'absorbed' + AND lineage.split_id IS NULL + )`, request.SourcePersonID); err != nil { + return fmt.Errorf("close fully split merge lineage: %w", err) + } + } + identityRevision, err := s.bumpIdentityRevisionContext(ctx, tx) + if err != nil { + return err + } + accountRevision, err := readAccountIdentityRevision(tx) + if err != nil { + return err + } + if err := s.recomputePersonSplitActivityTx( + ctx, tx, request.SourcePersonID, newPersonID, ContactRevisions{ + IdentityRevision: identityRevision, AccountIdentityRevision: accountRevision, + }, + ); err != nil { + return err + } + if err := s.bumpPersonRevisionsTx(ctx, tx, request.SourcePersonID, newPersonID); err != nil { + return err + } + exactReversal := selection.exact && len(unrestored) == 0 + if _, err := tx.ExecContext(ctx, `UPDATE person_splits + SET is_exact_reversal = ? WHERE id = ?`, exactReversal, splitID); err != nil { + return fmt.Errorf("record exact person split outcome: %w", err) + } + + split, err := s.getPersonSplitTx(ctx, tx, splitID) + if err != nil { + return err + } + source, err := s.getPersonTx(ctx, tx, request.SourcePersonID) + if err != nil { + return err + } + created, err := s.getPersonTx(ctx, tx, newPersonID) + if err != nil { + return err + } + result = &PersonSplitResult{ + Split: *split, SourcePerson: *source, NewPerson: *created, + ExactReversal: exactReversal, UIDAliasDisposition: aliasDisposition, + AmbiguousRows: ambiguous, UnrestoredRows: unrestored, + IdentityRevision: identityRevision, + } + resultJSON, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("encode person split idempotency result: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE person_splits + SET result_json = ?, identity_revision = ? WHERE id = ?`, + string(resultJSON), identityRevision, splitID); err != nil { + return fmt.Errorf("store person split idempotency result: %w", err) + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func personSplitHasPostMergeAcceptedCandidateTx( + ctx context.Context, tx *loggedTx, mergeID int64, snapshot personMergeSnapshot, +) (bool, error) { + rows, err := tx.QueryContext(ctx, `SELECT candidate.merge_id, journal.snapshot_path + FROM person_merge_review_candidates candidate + LEFT JOIN person_merge_rows journal + ON journal.merge_id = ? + AND journal.table_name = 'person_merge_review_candidates' + AND journal.origin_side = 'absorbed' + AND journal.original_row_id = candidate.id + WHERE candidate.state = 'accepted' + AND (candidate.merge_id = ? OR journal.snapshot_path IS NOT NULL) + ORDER BY candidate.id`, mergeID, mergeID) + if err != nil { + return false, fmt.Errorf("inspect exact-split review candidates: %w", err) + } + defer func() { _ = rows.Close() }() + snapshotByPath := make(map[string]personMergeSnapshotRow, len(snapshot.Rows)) + for index, row := range snapshot.Rows { + snapshotByPath["rows/"+strconv.Itoa(index)] = row + } + for rows.Next() { + var candidateMergeID int64 + var snapshotPath sql.NullString + if err := rows.Scan(&candidateMergeID, &snapshotPath); err != nil { + return false, fmt.Errorf("scan exact-split review candidate: %w", err) + } + if candidateMergeID == mergeID { + return true, nil + } + snapshotRow, ok := snapshotByPath[snapshotPath.String] + if !ok { + return false, fmt.Errorf("%w: missing candidate snapshot path %q", + ErrPersonMergeSnapshotCorrupt, snapshotPath.String) + } + if personSplitSnapshotRowText(snapshotRow, "state") != "accepted" { + return true, nil + } + } + if err := rows.Err(); err != nil { + return false, fmt.Errorf("iterate exact-split review candidates: %w", err) + } + return false, nil +} + +func personSplitRequestHash(request PersonSplitRequest) (string, error) { + canonical, err := json.Marshal(struct { + SourcePersonID int64 `json:"source_person_id"` + MergeID int64 `json:"merge_id"` + ParticipantIDs []int64 `json:"participant_ids"` + ExpectedSourceRevision int64 `json:"expected_source_revision"` + Actor string `json:"actor"` + }{request.SourcePersonID, request.MergeID, request.ParticipantIDs, + request.ExpectedSourceRevision, request.Actor}) + if err != nil { + return "", fmt.Errorf("encode person split request: %w", err) + } + digest := sha256.Sum256(canonical) + return hex.EncodeToString(digest[:]), nil +} + +func (s *Store) loadPersonSplitMergeTx( + ctx context.Context, tx *loggedTx, mergeID int64, +) (*PersonMerge, personMergeSnapshot, error) { + merge, err := s.getPersonMergeTx(ctx, tx, mergeID) + if err != nil { + return nil, personMergeSnapshot{}, err + } + var blob []byte + var hash string + if err := tx.QueryRowContext(ctx, `SELECT snapshot_blob, snapshot_sha256 + FROM person_merges WHERE id = ?`+s.dialect.SelectForUpdate(), mergeID).Scan(&blob, &hash); err != nil { + return nil, personMergeSnapshot{}, fmt.Errorf("load person merge snapshot: %w", err) + } + snapshot, err := decodePersonMergeSnapshot(blob, hash) + if err != nil { + return nil, personMergeSnapshot{}, err + } + return merge, snapshot, nil +} + +func (s *Store) loadPersonSplitLineageTx( + ctx context.Context, tx *loggedTx, mergeID int64, +) ([]personSplitLineage, error) { + locked, err := tx.QueryContext(ctx, `SELECT participant_id + FROM person_merge_participants WHERE merge_id = ? ORDER BY participant_id`+ + s.dialect.SelectForUpdate(), mergeID) + if err != nil { + return nil, fmt.Errorf("lock person split lineage: %w", err) + } + for locked.Next() { + var participantID int64 + if err := locked.Scan(&participantID); err != nil { + _ = locked.Close() + return nil, fmt.Errorf("scan locked person split lineage: %w", err) + } + } + if err := locked.Err(); err != nil { + _ = locked.Close() + return nil, fmt.Errorf("iterate locked person split lineage: %w", err) + } + if err := locked.Close(); err != nil { + return nil, fmt.Errorf("close locked person split lineage: %w", err) + } + rows, err := tx.QueryContext(ctx, `SELECT lineage.participant_id, lineage.origin_side, + lineage.split_id, binding.person_id + FROM person_merge_participants lineage + LEFT JOIN person_participants binding ON binding.participant_id = lineage.participant_id + WHERE lineage.merge_id = ? ORDER BY lineage.participant_id`, mergeID) + if err != nil { + return nil, fmt.Errorf("load person split lineage: %w", err) + } + defer func() { _ = rows.Close() }() + var result []personSplitLineage + for rows.Next() { + var item personSplitLineage + if err := rows.Scan(&item.participantID, &item.originSide, &item.splitID, &item.personID); err != nil { + return nil, fmt.Errorf("scan person split lineage: %w", err) + } + result = append(result, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person split lineage: %w", err) + } + return result, nil +} + +func (s *Store) loadPersonSplitAncestorRestorationsTx( + ctx context.Context, tx *loggedTx, request PersonSplitRequest, +) ([]personSplitAncestorRestoration, []int64, error) { + rows, err := tx.QueryContext(ctx, `SELECT id FROM person_merges + WHERE current_person_id = ? AND id < ? ORDER BY id`, + request.SourcePersonID, request.MergeID) + if err != nil { + return nil, nil, fmt.Errorf("load earlier active person merges: %w", err) + } + mergeIDs := []int64{} + for rows.Next() { + var mergeID int64 + if err := rows.Scan(&mergeID); err != nil { + _ = rows.Close() + return nil, nil, fmt.Errorf("scan earlier active person merge: %w", err) + } + mergeIDs = append(mergeIDs, mergeID) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, nil, fmt.Errorf("iterate earlier active person merges: %w", err) + } + if err := rows.Close(); err != nil { + return nil, nil, fmt.Errorf("close earlier active person merges: %w", err) + } + + selected := make(map[int64]struct{}, len(request.ParticipantIDs)) + for _, participantID := range request.ParticipantIDs { + selected[participantID] = struct{}{} + } + restorations := []personSplitAncestorRestoration{} + transfers := []int64{} + for _, mergeID := range mergeIDs { + lineage, err := s.loadPersonSplitLineageTx(ctx, tx, mergeID) + if err != nil { + return nil, nil, err + } + sourceBindings := 0 + selectedBindings := 0 + absorbedSelected := []int64{} + survivorSelected := false + for _, item := range lineage { + if !item.personID.Valid || item.personID.Int64 != request.SourcePersonID { + continue + } + sourceBindings++ + if _, ok := selected[item.participantID]; !ok { + continue + } + selectedBindings++ + if item.originSide == personMergeOriginAbsorbed && !item.splitID.Valid { + absorbedSelected = append(absorbedSelected, item.participantID) + } + survivorSelected = survivorSelected || item.originSide == personMergeOriginSurvivor + } + if selectedBindings == 0 { + continue + } + if selectedBindings == sourceBindings { + transfers = append(transfers, mergeID) + continue + } + if survivorSelected { + return nil, nil, ErrPersonSplitParticipants + } + if len(absorbedSelected) == 0 { + continue + } + selection, err := validatePersonSplitLineage( + lineage, request.SourcePersonID, absorbedSelected, + ) + if err != nil { + return nil, nil, err + } + if !selection.restoresAbsorbed { + return nil, nil, ErrPersonSplitParticipants + } + merge, snapshot, err := s.loadPersonSplitMergeTx(ctx, tx, mergeID) + if err != nil { + return nil, nil, err + } + reviewed, err := personSplitHasPostMergeAcceptedCandidateTx( + ctx, tx, mergeID, snapshot, + ) + if err != nil { + return nil, nil, err + } + if reviewed { + return nil, nil, ErrPersonSplitReviewed + } + absorbedRoot, err := absorbedPersonMergeSnapshotRoot(snapshot) + if err != nil { + return nil, nil, err + } + restorations = append(restorations, personSplitAncestorRestoration{ + merge: merge, snapshot: snapshot, absorbedRoot: absorbedRoot, + participantIDs: absorbedSelected, selection: selection, + }) + } + return restorations, transfers, nil +} + +func validatePersonSplitLineage( + lineage []personSplitLineage, sourceID int64, selected []int64, +) (personSplitLineageSelection, error) { + wanted := make(map[int64]struct{}, len(selected)) + for _, id := range selected { + wanted[id] = struct{}{} + } + absorbedUnsplit := 0 + absorbedTotal := 0 + matched := 0 + alreadySplit := false + survivorLineageIntact := true + for _, item := range lineage { + if item.originSide == personMergeOriginSurvivor && + (!item.personID.Valid || item.personID.Int64 != sourceID) { + survivorLineageIntact = false + } + if item.originSide == personMergeOriginAbsorbed { + absorbedTotal++ + if !item.splitID.Valid { + absorbedUnsplit++ + } + } + if _, ok := wanted[item.participantID]; !ok { + continue + } + matched++ + if item.originSide != personMergeOriginAbsorbed || + (!item.splitID.Valid && (!item.personID.Valid || item.personID.Int64 != sourceID)) { + return personSplitLineageSelection{}, ErrPersonSplitParticipants + } + alreadySplit = alreadySplit || item.splitID.Valid + } + if matched != len(selected) { + return personSplitLineageSelection{}, ErrPersonSplitParticipants + } + if alreadySplit { + return personSplitLineageSelection{}, ErrPersonMergeAlreadySplit + } + if len(selected) == 0 && absorbedTotal != 0 { + return personSplitLineageSelection{}, ErrPersonSplitParticipants + } + restoresAbsorbed := len(selected) == absorbedUnsplit && absorbedUnsplit == absorbedTotal + return personSplitLineageSelection{ + exact: survivorLineageIntact && restoresAbsorbed, + restoresAbsorbed: restoresAbsorbed, + }, nil +} + +func absorbedPersonMergeSnapshotRoot( + snapshot personMergeSnapshot, +) (personMergeSnapshotPerson, error) { + if len(snapshot.Persons) != 2 { + return personMergeSnapshotPerson{}, fmt.Errorf("%w: merge snapshot roots", ErrPersonMergeSnapshotCorrupt) + } + return snapshot.Persons[1], nil +} + +func (s *Store) deletePersonSplitCrossingLinksTx( + ctx context.Context, tx *loggedTx, selected []int64, +) error { + if len(selected) == 0 { + return nil + } + if err := s.rejectAcceptedIdentityMatchesAcrossPersonSplitTx(ctx, tx, selected); err != nil { + return err + } + args := append(personMergeSnapshotIDArgs(selected), personMergeSnapshotIDArgs(selected)...) + placeholders := personMergeSnapshotPlaceholders(len(selected)) + if _, err := tx.ExecContext(ctx, `DELETE FROM participant_links + WHERE (participant_a IN (`+placeholders+`) AND participant_b NOT IN (`+placeholders+`)) + OR (participant_b IN (`+placeholders+`) AND participant_a NOT IN (`+placeholders+`))`, + append(args, args...)...); err != nil { + return fmt.Errorf("cut split identity links: %w", err) + } + return nil +} + +func (s *Store) restorePersonSplitRowsTx( + ctx context.Context, + tx *loggedTx, + mergeID, splitID, sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, + sourceUID, newUID string, + selected []int64, + selection personSplitLineageSelection, + snapshot personMergeSnapshot, + unrestored *[]PersonMergeRowRef, +) ([]PersonMergeRowRef, error) { + journal, err := loadPersonSplitJournalTx(ctx, tx, mergeID) + if err != nil { + return nil, err + } + selectedSet := make(map[int64]struct{}, len(selected)) + for _, participantID := range selected { + selectedSet[participantID] = struct{}{} + } + rowsByPath := make(map[string]personMergeSnapshotRow, len(snapshot.Rows)) + for index, row := range snapshot.Rows { + rowsByPath["rows/"+strconv.Itoa(index)] = row + } + + move := make([]personSplitJournalRow, 0, len(journal)) + ambiguous := make([]PersonMergeRowRef, 0) + for _, row := range journal { + eligible := selection.exact || + (selection.restoresAbsorbed && row.originSide == personMergeOriginAbsorbed) + if !eligible && row.provenance == personMergeProvenanceParticipantExact && + row.participantID.Valid { + _, eligible = selectedSet[row.participantID.Int64] + } + if eligible { + move = append(move, row) + continue + } + if row.originSide == personMergeOriginAbsorbed { + ambiguous = append(ambiguous, personSplitJournalRef(row)) + } + } + unsupportedCandidates, err := s.personSplitUnsupportedGeneratedCandidatesTx( + ctx, tx, move, rowsByPath, + ) + if err != nil { + return nil, err + } + unsupportedEvidence, err := s.personSplitUnsupportedGeneratedEvidenceTx( + ctx, tx, move, rowsByPath, unsupportedCandidates, + ) + if err != nil { + return nil, err + } + + // Recreate deleted and removed deduplicated parent rows first. Existing dependent + // rows may need their merge-time foreign-key remaps reversed afterward. + slices.SortStableFunc(move, func(left, right personSplitJournalRow) int { + return personSplitRestorePriority(left.tableName) - personSplitRestorePriority(right.tableName) + }) + for _, row := range move { + if row.provenance == personMergeProvenanceDerived || row.action == "recomputed" { + if err := markPersonSplitJournalRowTx(ctx, tx, mergeID, splitID, row); err != nil { + return nil, err + } + continue + } + if row.action != "deleted_snapshot" && + (row.action != personMergeActionDeduplicated || personSplitJournalRowWasRetained(row)) { + continue + } + snapshotRow, ok := rowsByPath[row.snapshotPath] + if !ok { + return nil, fmt.Errorf("%w: missing split snapshot path %q", + ErrPersonMergeSnapshotCorrupt, row.snapshotPath) + } + if personSplitSnapshotHasUnsupportedCandidate( + snapshotRow, unsupportedCandidates, unsupportedEvidence, + ) { + appendPersonSplitUnrestoredRow(unrestored, selection, row) + if err := s.rebasePriorPersonMergeRowsAfterSplitTx( + ctx, tx, mergeID, row, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, false, + ); err != nil { + return nil, err + } + if err := markPersonSplitJournalRowTx(ctx, tx, mergeID, splitID, row); err != nil { + return nil, err + } + continue + } + if row.action == personMergeActionDeduplicated { + var restore bool + snapshotRow, restore, err = s.preparePersonSplitDeduplicatedRowTx( + ctx, tx, row, snapshotRow, + ) + if err != nil { + return nil, err + } + if !restore { + appendPersonSplitUnrestoredRow(unrestored, selection, row) + // Deleting or reassigning the surviving merged row is post-merge + // user state. Do not recreate a duplicate under stale ownership. + if err := s.rebasePriorPersonMergeRowsAfterSplitTx( + ctx, tx, mergeID, row, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, false, + ); err != nil { + return nil, err + } + if err := markPersonSplitJournalRowTx(ctx, tx, mergeID, splitID, row); err != nil { + return nil, err + } + continue + } + } + dependenciesPresent, err := s.personSplitSnapshotDependenciesPresentTx( + ctx, tx, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, + ) + if err != nil { + return nil, err + } + recordTargetPresent, err := s.personSplitSnapshotRecordTargetPresentTx( + ctx, tx, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, + ) + if err != nil { + return nil, err + } + dependenciesPresent = dependenciesPresent && recordTargetPresent + if !dependenciesPresent { + appendPersonSplitUnrestoredRow(unrestored, selection, row) + // Dependency removal after the merge is user state. Preserve the + // deleted row instead of turning an exact split into an FK failure. + if err := s.rebasePriorPersonMergeRowsAfterSplitTx( + ctx, tx, mergeID, row, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, false, + ); err != nil { + return nil, err + } + if err := markPersonSplitJournalRowTx(ctx, tx, mergeID, splitID, row); err != nil { + return nil, err + } + continue + } + if err := s.insertPersonSplitSnapshotRowTx( + ctx, tx, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, + ); err != nil { + return nil, err + } + if err := s.rebasePriorPersonMergeRowsAfterSplitTx( + ctx, tx, mergeID, row, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, false, + ); err != nil { + return nil, err + } + if err := markPersonSplitJournalRowTx(ctx, tx, mergeID, splitID, row); err != nil { + return nil, err + } + } + for _, row := range move { + if row.provenance == personMergeProvenanceDerived || row.action == "recomputed" || + row.action == "deleted_snapshot" || + (row.action == personMergeActionDeduplicated && !personSplitJournalRowWasRetained(row)) { + continue + } + snapshotRow, ok := rowsByPath[row.snapshotPath] + if !ok { + return nil, fmt.Errorf("%w: missing split snapshot path %q", + ErrPersonMergeSnapshotCorrupt, row.snapshotPath) + } + if personSplitSnapshotHasUnsupportedCandidate( + snapshotRow, unsupportedCandidates, unsupportedEvidence, + ) { + appendPersonSplitUnrestoredRow(unrestored, selection, row) + if err := s.rebasePriorPersonMergeRowsAfterSplitTx( + ctx, tx, mergeID, row, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, false, + ); err != nil { + return nil, err + } + if err := markPersonSplitJournalRowTx(ctx, tx, mergeID, splitID, row); err != nil { + return nil, err + } + continue + } + replaced, err := s.personSplitTrackingRowReplacedTx(ctx, tx, row) + if err != nil { + return nil, err + } + if replaced { + if err := s.insertPersonSplitSnapshotRowTx( + ctx, tx, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, + ); err != nil { + return nil, err + } + if err := s.rebasePriorPersonMergeRowsAfterSplitTx( + ctx, tx, mergeID, row, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, true, + ); err != nil { + return nil, err + } + if err := markPersonSplitJournalRowTx(ctx, tx, mergeID, splitID, row); err != nil { + return nil, err + } + continue + } + if err := s.restoreExistingPersonSplitRowTx( + ctx, tx, row, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, + ); err != nil { + return nil, err + } + if err := s.rebasePriorPersonMergeRowsAfterSplitTx( + ctx, tx, mergeID, row, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, false, + ); err != nil { + return nil, err + } + if err := markPersonSplitJournalRowTx(ctx, tx, mergeID, splitID, row); err != nil { + return nil, err + } + } + return ambiguous, nil +} + +func appendPersonSplitUnrestoredRow( + rows *[]PersonMergeRowRef, selection personSplitLineageSelection, row personSplitJournalRow, +) { + if !selection.exact && + (!selection.restoresAbsorbed || row.originSide != personMergeOriginAbsorbed) { + return + } + var originalID *int64 + if row.originalRowID.Valid { + id := row.originalRowID.Int64 + originalID = &id + } + *rows = append(*rows, PersonMergeRowRef{ + TableName: row.tableName, OriginalRowID: originalID, + OriginalKey: row.originalKey, Action: row.action, + }) +} + +func (s *Store) personSplitUnsupportedGeneratedCandidatesTx( + ctx context.Context, + tx *loggedTx, + journal []personSplitJournalRow, + rowsByPath map[string]personMergeSnapshotRow, +) (map[int64]struct{}, error) { + candidates := make(map[int64]personMergeSnapshotRow) + supportSources := make(map[int64][]int64) + for _, item := range journal { + row, ok := rowsByPath[item.snapshotPath] + if !ok { + return nil, fmt.Errorf("%w: missing split snapshot path %q", + ErrPersonMergeSnapshotCorrupt, item.snapshotPath) + } + switch row.TableName { + case identityMatchCandidatesTableName: + candidates[row.RowID] = row + case identityMatchCandidateSourcesTableName: + candidateID := personSplitSnapshotRowInteger(row, "candidate_id") + sourceID := personSplitSnapshotRowInteger(row, sourceIDColumnName) + if candidateID > 0 && sourceID > 0 { + supportSources[candidateID] = append(supportSources[candidateID], sourceID) + } + } + } + unsupported := make(map[int64]struct{}) + for candidateID, candidate := range candidates { + source := Provenance(personSplitSnapshotRowText(candidate, "source")) + if source != ProvenanceArchiveObservation && source != ProvenanceExtraction && + source != ProvenanceEnrichment { + continue + } + if personSplitSnapshotRowText(candidate, "decided_by") == string(ProvenanceUser) { + continue + } + var supported bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM identity_match_candidate_sources support + JOIN sources source ON source.id = support.source_id + WHERE support.candidate_id = ? + )`, candidateID).Scan(&supported); err != nil { + return nil, fmt.Errorf("inspect current split candidate support: %w", err) + } + for _, sourceID := range supportSources[candidateID] { + if supported { + break + } + if err := tx.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM sources WHERE id = ?)`, sourceID, + ).Scan(&supported); err != nil { + return nil, fmt.Errorf("inspect restored split candidate support: %w", err) + } + } + if !supported { + unsupported[candidateID] = struct{}{} + } + } + return unsupported, nil +} + +func (s *Store) personSplitUnsupportedGeneratedEvidenceTx( + ctx context.Context, + tx *loggedTx, + journal []personSplitJournalRow, + rowsByPath map[string]personMergeSnapshotRow, + unsupportedCandidates map[int64]struct{}, +) (map[int64]struct{}, error) { + evidenceRows := make(map[int64]personMergeSnapshotRow) + supportSources := make(map[int64][]int64) + for _, item := range journal { + row, ok := rowsByPath[item.snapshotPath] + if !ok { + return nil, fmt.Errorf("%w: missing split snapshot path %q", + ErrPersonMergeSnapshotCorrupt, item.snapshotPath) + } + switch row.TableName { + case identityMatchEvidenceTableName: + evidenceRows[row.RowID] = row + case identityMatchEvidenceSourcesTableName: + evidenceID := personSplitSnapshotRowInteger(row, "evidence_id") + sourceID := personSplitSnapshotRowInteger(row, sourceIDColumnName) + if evidenceID > 0 && sourceID > 0 { + supportSources[evidenceID] = append(supportSources[evidenceID], sourceID) + } + } + } + unsupported := make(map[int64]struct{}) + for evidenceID, evidence := range evidenceRows { + candidateID := personSplitSnapshotRowInteger(evidence, "candidate_id") + if _, skip := unsupportedCandidates[candidateID]; skip { + unsupported[evidenceID] = struct{}{} + continue + } + source := Provenance(personSplitSnapshotRowText(evidence, "source")) + if source != ProvenanceArchiveObservation && source != ProvenanceExtraction && + source != ProvenanceEnrichment { + continue + } + var supported bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM identity_match_evidence_sources support + JOIN sources source ON source.id = support.source_id + WHERE support.evidence_id = ? + )`, evidenceID).Scan(&supported); err != nil { + return nil, fmt.Errorf("inspect current split evidence support: %w", err) + } + for _, sourceID := range supportSources[evidenceID] { + if supported { + break + } + if err := tx.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM sources WHERE id = ?)`, sourceID, + ).Scan(&supported); err != nil { + return nil, fmt.Errorf("inspect restored split evidence support: %w", err) + } + } + if !supported { + unsupported[evidenceID] = struct{}{} + } + } + return unsupported, nil +} + +func personSplitSnapshotHasUnsupportedCandidate( + row personMergeSnapshotRow, + unsupportedCandidates, unsupportedEvidence map[int64]struct{}, +) bool { + switch row.TableName { + case identityMatchCandidatesTableName: + _, skip := unsupportedCandidates[row.RowID] + return skip + case identityMatchCandidateSourcesTableName: + _, skip := unsupportedCandidates[personSplitSnapshotRowInteger(row, "candidate_id")] + return skip + case identityMatchEvidenceTableName: + _, candidateUnsupported := unsupportedCandidates[personSplitSnapshotRowInteger(row, "candidate_id")] + _, evidenceUnsupported := unsupportedEvidence[row.RowID] + return candidateUnsupported || evidenceUnsupported + case identityMatchEvidenceSourcesTableName: + _, skip := unsupportedEvidence[personSplitSnapshotRowInteger(row, "evidence_id")] + return skip + case "identity_match_candidate_redirects": + _, skip := unsupportedCandidates[personSplitSnapshotRowInteger(row, "surviving_candidate_id")] + return skip + default: + return false + } +} + +type personSplitSnapshotDependency struct { + column, table, key string +} + +var errPersonSplitReferenceMissing = errors.New("person split reference target is missing") + +var personSplitSnapshotDependencies = map[string][]personSplitSnapshotDependency{ + personRelationshipsTableName: { + {column: "relationship_type_id", table: "relationship_types", key: "id"}, + }, + identityMatchCandidateSourcesTableName: { + {column: sourceIDColumnName, table: "sources", key: "id"}, + }, + identityMatchEvidenceSourcesTableName: { + {column: sourceIDColumnName, table: "sources", key: "id"}, + }, +} + +var personSplitSnapshotColumnDependencies = map[string]map[string]personSplitSnapshotDependency{ + personRelationshipReviewsTableName: { + "accepted_relationship_id": { + column: "accepted_relationship_id", table: personRelationshipsTableName, key: "id", + }, + "matched_person_id": { + column: "matched_person_id", table: "persons", key: "id", + }, + }, + identityMatchCandidatesTableName: { + "service_id": { + column: "service_id", table: "communication_services", key: "id", + }, + }, +} + +func (s *Store) personSplitSnapshotDependenciesPresentTx( + ctx context.Context, + tx *loggedTx, + row personMergeSnapshotRow, + sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, +) (bool, error) { + for _, dependency := range personSplitSnapshotDependencies[row.TableName] { + value := personSplitSnapshotRowInteger(row, dependency.column) + if value <= 0 { + return false, fmt.Errorf("%w: missing %s dependency %q", + ErrPersonMergeSnapshotCorrupt, row.TableName, dependency.column) + } + var present bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM `+ + personSplitIdentifier(dependency.table)+` WHERE `+ + personSplitIdentifier(dependency.key)+` = ?)`, value).Scan(&present); err != nil { + return false, fmt.Errorf("inspect %s split dependency %s: %w", + row.TableName, dependency.column, err) + } + if !present { + return false, nil + } + } + spec, ok := personMergeTableRegistry[row.TableName] + if !ok { + return false, fmt.Errorf("%w: unregistered split table %q", + ErrPersonMergeInvalid, row.TableName) + } + for _, reference := range spec.PersonReferences { + if reference.Kind == personMergeReferencePolymorphic && + personSplitSnapshotRowText(row, reference.KindColumn) != reference.KindValue { + continue + } + historicalID := personSplitSnapshotRowInteger(row, reference.IDColumn) + if historicalID <= 0 { + continue + } + _, present, err := s.personSplitResolvePersonReferenceTx( + ctx, tx, historicalID, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, + ) + if err != nil { + return false, err + } + if !present && !personSplitNullablePersonReference(row.TableName, reference.IDColumn) { + return false, nil + } + } + return true, nil +} + +func (s *Store) personSplitSnapshotColumnDependencyPresentTx( + ctx context.Context, + tx *loggedTx, + row personMergeSnapshotRow, + column personMergeSnapshotColumn, + sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, +) (bool, error) { + dependencies := personSplitSnapshotColumnDependencies[row.TableName] + dependency, ok := dependencies[column.Name] + if !ok || column.Value.Integer == nil { + return true, nil + } + if dependency.table == "persons" { + _, present, err := s.personSplitResolvePersonReferenceTx( + ctx, tx, *column.Value.Integer, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, + ) + return present, err + } + var present bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM `+ + personSplitIdentifier(dependency.table)+` WHERE `+ + personSplitIdentifier(dependency.key)+` = ?)`, *column.Value.Integer).Scan(&present); err != nil { + return false, fmt.Errorf("inspect %s split column dependency %s: %w", + row.TableName, dependency.column, err) + } + return present, nil +} + +func (s *Store) personSplitSnapshotRecordTargetPresentTx( + ctx context.Context, + tx *loggedTx, + row personMergeSnapshotRow, + sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, +) (bool, error) { + if (row.TableName != personAttributeValuesTableName && + row.TableName != "organization_attribute_values") || + personSplitSnapshotRowText(row, "value_record_type") != "person" { + return true, nil + } + targetID := personSplitSnapshotRowInteger(row, "value_record_id") + if targetID <= 0 { + return false, fmt.Errorf("%w: missing %s record target", + ErrPersonMergeSnapshotCorrupt, row.TableName) + } + _, present, err := s.personSplitResolvePersonReferenceTx( + ctx, tx, targetID, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, + ) + if err != nil { + return false, fmt.Errorf("inspect %s split record target: %w", row.TableName, err) + } + return present, nil +} + +func personSplitNullablePersonReference(table, column string) bool { + return (table == personRelationshipReviewsTableName && column == "matched_person_id") || + (table == "person_uid_aliases" && column == "surviving_person_id") || + (table == "person_merges" && column == "current_person_id") +} + +func (s *Store) personSplitResolvePersonReferenceTx( + ctx context.Context, + tx *loggedTx, + historicalID, sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, +) (int64, bool, error) { + switch historicalID { + case survivorSnapshotID: + return sourceID, true, nil + case absorbedSnapshotID: + return newPersonID, true, nil + } + var resolvedID int64 + err := tx.QueryRowContext(ctx, `SELECT id FROM persons WHERE id = ?`, historicalID). + Scan(&resolvedID) + if err == nil { + return resolvedID, true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return 0, false, fmt.Errorf("resolve split person reference %d: %w", historicalID, err) + } + err = tx.QueryRowContext(ctx, `SELECT + COALESCE(alias.surviving_person_id, merge_record.current_person_id) + FROM person_merges merge_record + LEFT JOIN person_uid_aliases alias + ON alias.retired_uid = merge_record.absorbed_uid + JOIN persons current_person + ON current_person.id = COALESCE(alias.surviving_person_id, merge_record.current_person_id) + WHERE merge_record.absorbed_person_id = ? + ORDER BY merge_record.id DESC LIMIT 1`, historicalID).Scan(&resolvedID) + if errors.Is(err, sql.ErrNoRows) { + return 0, false, nil + } + if err != nil { + return 0, false, fmt.Errorf("resolve split person merge lineage %d: %w", historicalID, err) + } + return resolvedID, true, nil +} + +func personSplitJournalRowWasRetained(row personSplitJournalRow) bool { + if row.action != personMergeActionDeduplicated { + return false + } + if row.originalRowID.Valid && row.currentRowID.Valid { + return row.originalRowID.Int64 == row.currentRowID.Int64 + } + return !row.originalRowID.Valid && !row.currentRowID.Valid && + row.currentKey.Valid && row.originalKey == row.currentKey.String +} + +func (s *Store) reconcilePersonSplitCounterpartProjectionsTx( + ctx context.Context, tx *loggedTx, splitID, sourceID, newPersonID int64, +) error { + // A fresh person ID can reverse the stable ordering used by symmetric + // relationship rows, so restore their canonical endpoint order first. + if _, err := tx.ExecContext(ctx, `UPDATE person_relationships SET + source_person_id = target_person_id, + target_person_id = source_person_id + WHERE id IN ( + SELECT original_row_id FROM person_merge_rows + WHERE split_id = ? AND table_name = 'person_relationships' + AND original_row_id IS NOT NULL + ) + AND source_person_id > target_person_id + AND relationship_type_id IN ( + SELECT id FROM relationship_types WHERE is_symmetric = TRUE + )`, splitID); err != nil { + return fmt.Errorf("canonicalize split relationships: %w", err) + } + queries := []struct { + query string + args []any + }{ + { + query: `SELECT source_person_id FROM person_relationships WHERE id IN ( + SELECT original_row_id FROM person_merge_rows + WHERE split_id = ? AND table_name = 'person_relationships' + ) UNION SELECT target_person_id FROM person_relationships WHERE id IN ( + SELECT original_row_id FROM person_merge_rows + WHERE split_id = ? AND table_name = 'person_relationships' + )`, + args: []any{splitID, splitID}, + }, + { + query: `SELECT value.person_id FROM person_attribute_values value + JOIN person_merge_rows journal ON journal.original_row_id = value.id + WHERE journal.split_id = ? AND journal.table_name = 'person_attribute_values' + AND journal.provenance_kind = 'inbound_reference'`, + args: []any{splitID}, + }, + { + query: `SELECT employment.person_id + FROM organization_attribute_values value + JOIN person_merge_rows journal ON journal.original_row_id = value.id + JOIN employments employment ON employment.organization_id = value.organization_id + WHERE journal.split_id = ? + AND journal.table_name = 'organization_attribute_values' + AND journal.provenance_kind = 'inbound_reference'`, + args: []any{splitID}, + }, + { + query: `SELECT review.person_id + FROM person_relationship_reviews review + JOIN person_merge_rows journal ON journal.original_row_id = review.id + WHERE journal.split_id = ? + AND journal.table_name = 'person_relationship_reviews'`, + args: []any{splitID}, + }, + } + people := []int64{} + for _, item := range queries { + ids, err := personMergeRowIDsTx(ctx, tx, item.query, item.args...) + if err != nil { + return fmt.Errorf("load split counterpart projections: %w", err) + } + for _, personID := range ids { + if personID != sourceID && personID != newPersonID { + people = append(people, personID) + } + } + } + return s.bumpPersonVCardProjectionsTx(ctx, tx, people...) +} + +func loadPersonSplitJournalTx( + ctx context.Context, tx *loggedTx, mergeID int64, +) ([]personSplitJournalRow, error) { + rows, err := tx.QueryContext(ctx, `SELECT table_name, original_row_id, + original_row_key, current_row_id, current_row_key, provenance_kind, origin_side, + participant_id, action, snapshot_path, post_merge_row_json + FROM person_merge_rows WHERE merge_id = ? AND split_id IS NULL + ORDER BY table_name, original_row_key`, mergeID) + if err != nil { + return nil, fmt.Errorf("load person split journal: %w", err) + } + defer func() { _ = rows.Close() }() + result := []personSplitJournalRow{} + for rows.Next() { + var row personSplitJournalRow + if err := rows.Scan( + &row.tableName, &row.originalRowID, &row.originalKey, + &row.currentRowID, &row.currentKey, &row.provenance, &row.originSide, + &row.participantID, &row.action, &row.snapshotPath, &row.postMergeJSON, + ); err != nil { + return nil, fmt.Errorf("scan person split journal: %w", err) + } + result = append(result, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person split journal: %w", err) + } + return result, nil +} + +func personSplitJournalRef(row personSplitJournalRow) PersonMergeRowRef { + var id *int64 + if row.originalRowID.Valid { + value := row.originalRowID.Int64 + id = &value + } + return PersonMergeRowRef{ + TableName: row.tableName, OriginalRowID: id, + OriginalKey: row.originalKey, Action: row.action, + } +} + +func personSplitRestorePriority(table string) int { + switch table { + case identityMatchCandidatesTableName, personRelationshipsTableName, personAttributeValuesTableName: + return 10 + case "identity_match_candidate_redirects", identityMatchCandidateSourcesTableName, + identityMatchEvidenceTableName, personRelationshipReviewsTableName, + personMergeReviewCandidatesTableName: + return 20 + case identityMatchEvidenceSourcesTableName: + return 30 + default: + return 15 + } +} + +func (s *Store) restoreExistingPersonSplitRowTx( + ctx context.Context, + tx *loggedTx, + journal personSplitJournalRow, + row personMergeSnapshotRow, + sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, + sourceUID, newUID string, +) error { + spec, ok := personMergeTableRegistry[row.TableName] + if !ok { + return fmt.Errorf("%w: unregistered split table %q", ErrPersonMergeInvalid, row.TableName) + } + where, whereArgs, err := personSplitCurrentRowWhere(spec, journal) + if err != nil { + return err + } + currentRows, err := s.capturePersonMergeQueryTx(ctx, tx, spec, + `SELECT * FROM `+personSplitIdentifier(row.TableName)+` WHERE `+where, + whereArgs, absorbedSnapshotID) + if err != nil { + return err + } + if len(currentRows) == 0 { + // A supported post-merge deletion is user state, not merge damage. + // Leave the row absent and close its journal entry below. + return nil + } + if len(currentRows) != 1 { + return fmt.Errorf("%w: current %s row is missing", ErrPersonSplitParticipants, row.TableName) + } + recordTargetPresent, err := s.personSplitSnapshotRecordTargetPresentTx( + ctx, tx, row, sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID, + ) + if err != nil { + return err + } + currentByName := personSplitSnapshotColumnsByName(currentRows[0].Columns) + postByName := map[string]personMergeSnapshotValue{} + if journal.postMergeJSON.Valid { + var post personMergeSnapshotRow + if err := json.Unmarshal([]byte(journal.postMergeJSON.String), &post); err != nil { + return fmt.Errorf("%w: decode %s post-merge row: %w", + ErrPersonMergeSnapshotCorrupt, row.TableName, err) + } + postByName = personSplitSnapshotColumnsByName(post.Columns) + } + keyColumns := make(map[string]struct{}, len(spec.keyColumns())) + for _, name := range spec.keyColumns() { + keyColumns[name] = struct{}{} + } + assignments := make([]string, 0, len(row.Columns)) + args := make([]any, 0, len(row.Columns)+3) + for _, column := range row.Columns { + if !recordTargetPresent && + (column.Name == "active_until" || column.Name == "superseded_at") { + continue + } + if personSplitRevisionedTable(row.TableName) && + (column.Name == "revision" || column.Name == "updated_at") { + continue + } + dependencyPresent, err := s.personSplitSnapshotColumnDependencyPresentTx( + ctx, tx, row, column, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, + ) + if err != nil { + return err + } + if !dependencyPresent { + continue + } + value, restore, personReference, err := s.personSplitExistingColumnValueTx( + ctx, tx, column, row, spec, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, + ) + if err != nil { + return err + } + if restore && personReference && journal.postMergeJSON.Valid { + postValue, hasPost := postByName[column.Name] + currentValue, hasCurrent := currentByName[column.Name] + if hasPost && hasCurrent && !reflect.DeepEqual(currentValue, postValue) { + restore = false + } + } + if !restore && !personReference && journal.postMergeJSON.Valid { + _, isKey := keyColumns[column.Name] + postValue, hasPost := postByName[column.Name] + currentValue, hasCurrent := currentByName[column.Name] + // A repointed composite row is the same physical fact under a + // merge-created key. Restore that key only while it still matches + // the recorded post-merge state; ordinary keys remain immutable. + if (!isKey || journal.action == "repointed") && hasPost && hasCurrent && + !reflect.DeepEqual(column.Value, postValue) && + reflect.DeepEqual(currentValue, postValue) { + value = personSplitSnapshotValue(column.Value) + restore = true + } + } + if !restore { + continue + } + assignments = append(assignments, personSplitIdentifier(column.Name)+" = ?") + args = append(args, value) + } + if personSplitRevisionedTable(row.TableName) { + assignments = append(assignments, + "revision = revision + 1", "updated_at = "+s.dialect.Now()) + } + if len(assignments) == 0 { + return nil + } + args = append(args, whereArgs...) + result, err := tx.ExecContext(ctx, `UPDATE `+personSplitIdentifier(row.TableName)+ + ` SET `+strings.Join(assignments, ", ")+` WHERE `+where, args...) + if err != nil { + return fmt.Errorf("restore split row %s: %w", row.TableName, err) + } + if changed, err := result.RowsAffected(); err != nil { + return fmt.Errorf("count restored split row %s: %w", row.TableName, err) + } else if changed != 1 { + return fmt.Errorf("%w: current %s row is missing", ErrPersonSplitParticipants, row.TableName) + } + return nil +} + +func personSplitSnapshotColumnsByName( + columns []personMergeSnapshotColumn, +) map[string]personMergeSnapshotValue { + result := make(map[string]personMergeSnapshotValue, len(columns)) + for _, column := range columns { + result[column.Name] = column.Value + } + return result +} + +func rebasePersonMergePostRowReferences( + encoded sql.NullString, + current personMergeSnapshotRow, + spec personMergeTableSpec, + replacements map[int64]int64, +) (sql.NullString, bool, error) { + if !encoded.Valid || encoded.String == "" { + return encoded, false, nil + } + var post personMergeSnapshotRow + if err := json.Unmarshal([]byte(encoded.String), &post); err != nil { + return sql.NullString{}, false, fmt.Errorf("%w: decode rebased %s post-merge row: %w", + ErrPersonMergeSnapshotCorrupt, spec.TableName, err) + } + currentByName := personSplitSnapshotColumnsByName(current.Columns) + changed := false + for _, reference := range spec.PersonReferences { + if reference.Kind == personMergeReferencePolymorphic && + personSplitSnapshotRowText(post, reference.KindColumn) != reference.KindValue { + continue + } + for index := range post.Columns { + column := &post.Columns[index] + if column.Name != reference.IDColumn || column.Value.Integer == nil { + continue + } + target, ok := replacements[*column.Value.Integer] + if !ok { + continue + } + currentValue, ok := currentByName[column.Name] + if !ok || currentValue.Integer == nil || *currentValue.Integer != target { + continue + } + column.Value = currentValue + changed = true + } + } + if !changed { + return encoded, false, nil + } + postByName := personSplitSnapshotColumnsByName(post.Columns) + keyColumns := make([]personMergeSnapshotColumn, 0, len(spec.keyColumns())) + for _, name := range spec.keyColumns() { + value, ok := postByName[name] + if !ok { + return sql.NullString{}, false, fmt.Errorf("%w: rebased %s post-merge key", + ErrPersonMergeSnapshotCorrupt, spec.TableName) + } + keyColumns = append(keyColumns, personMergeSnapshotColumn{Name: name, Value: value}) + } + rowKey, err := canonicalPersonMergeSnapshotRowKey(keyColumns, spec.keyColumns()) + if err != nil { + return sql.NullString{}, false, err + } + post.RowKey = rowKey + rebased, err := json.Marshal(post) + if err != nil { + return sql.NullString{}, false, fmt.Errorf("encode rebased %s post-merge row: %w", + spec.TableName, err) + } + return sql.NullString{String: string(rebased), Valid: true}, true, nil +} + +func (s *Store) personSplitExistingColumnValueTx( + ctx context.Context, + tx *loggedTx, + column personMergeSnapshotColumn, + row personMergeSnapshotRow, + spec personMergeTableSpec, + sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, + sourceUID, newUID string, +) (any, bool, bool, error) { + if spec.TableName == "vcard_resource_envelopes" && column.Name == "canonical_person_uid" { + switch personSplitSnapshotRowInteger(row, "person_id") { + case absorbedSnapshotID: + return newUID, true, false, nil + case survivorSnapshotID: + return sourceUID, true, false, nil + } + } + for _, reference := range spec.PersonReferences { + if reference.IDColumn != column.Name { + continue + } + if reference.Kind == personMergeReferencePolymorphic && + personSplitSnapshotRowText(row, reference.KindColumn) != reference.KindValue { + continue + } + if column.Value.Integer == nil { + return nil, true, true, nil + } + resolvedID, present, err := s.personSplitResolvePersonReferenceTx( + ctx, tx, *column.Value.Integer, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, + ) + if err != nil { + return nil, false, true, err + } + if !present { + return nil, false, true, nil + } + return resolvedID, true, true, nil + } + return nil, false, false, nil +} + +func (s *Store) insertPersonSplitSnapshotRowTx( + ctx context.Context, + tx *loggedTx, + row personMergeSnapshotRow, + sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, + sourceUID, newUID string, +) error { + spec, ok := personMergeTableRegistry[row.TableName] + if !ok { + return fmt.Errorf("%w: unregistered split table %q", ErrPersonMergeInvalid, row.TableName) + } + if row.TableName == identityMatchCandidatesTableName { + if _, err := tx.ExecContext(ctx, `DELETE FROM identity_match_candidate_redirects + WHERE retired_candidate_id = ?`, row.RowID); err != nil { + return fmt.Errorf("remove split candidate redirect: %w", err) + } + } + columns := make([]string, 0, len(row.Columns)) + args := make([]any, 0, len(row.Columns)) + for _, column := range row.Columns { + if personSplitRevisionedTable(row.TableName) && column.Name == "updated_at" { + continue + } + columns = append(columns, personSplitIdentifier(column.Name)) + dependencyPresent, err := s.personSplitSnapshotColumnDependencyPresentTx( + ctx, tx, row, column, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, + ) + if err != nil { + return err + } + var value any + if dependencyPresent { + value, err = s.personSplitSnapshotColumnValueTx( + ctx, tx, column, row, spec, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, + ) + if errors.Is(err, errPersonSplitReferenceMissing) { + value = nil + } else if err != nil { + return err + } + } + if personSplitRevisionedTable(row.TableName) && column.Name == "revision" { + revision, ok := value.(int64) + if !ok { + return fmt.Errorf("%w: invalid %s revision", ErrPersonMergeSnapshotCorrupt, row.TableName) + } + value = revision + 1 + } + args = append(args, value) + } + table := personSplitIdentifier(row.TableName) + insert := `INSERT INTO ` + table + if s.IsPostgreSQL() { + insert += ` (` + strings.Join(columns, ", ") + `) OVERRIDING SYSTEM VALUE` + } else { + insert += ` (` + strings.Join(columns, ", ") + `)` + } + insert += ` VALUES (` + personMergeSnapshotPlaceholders(len(columns)) + `)` + if _, err := tx.ExecContext(ctx, insert, args...); err != nil { + return fmt.Errorf("recreate split row %s: %w", row.TableName, err) + } + return nil +} + +func personSplitRevisionedTable(table string) bool { + switch table { + case "vcard_resource_envelopes", personRelationshipsTableName, "employments": + return true + default: + return false + } +} + +func (s *Store) personSplitSnapshotColumnValueTx( + ctx context.Context, + tx *loggedTx, + column personMergeSnapshotColumn, + row personMergeSnapshotRow, + spec personMergeTableSpec, + sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, + sourceUID, newUID string, +) (any, error) { + value := personSplitSnapshotValue(column.Value) + for _, reference := range spec.PersonReferences { + if reference.IDColumn != column.Name { + continue + } + if reference.Kind == personMergeReferencePolymorphic && + personSplitSnapshotRowText(row, reference.KindColumn) != reference.KindValue { + continue + } + if column.Value.Integer == nil { + return value, nil + } + resolvedID, present, err := s.personSplitResolvePersonReferenceTx( + ctx, tx, *column.Value.Integer, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, + ) + if err != nil { + return nil, err + } + if !present { + return nil, errPersonSplitReferenceMissing + } + return resolvedID, nil + } + if spec.TableName == "vcard_resource_envelopes" && column.Name == "canonical_person_uid" { + switch personSplitSnapshotRowInteger(row, "person_id") { + case absorbedSnapshotID: + return newUID, nil + case survivorSnapshotID: + return sourceUID, nil + } + } + return value, nil +} + +func personSplitSnapshotRowText(row personMergeSnapshotRow, name string) string { + for _, column := range row.Columns { + if column.Name == name && column.Value.Text != nil { + return *column.Value.Text + } + } + return "" +} + +func personSplitSnapshotRowInteger(row personMergeSnapshotRow, name string) int64 { + for _, column := range row.Columns { + if column.Name == name && column.Value.Integer != nil { + return *column.Value.Integer + } + } + return 0 +} + +func personSplitSnapshotValue(value personMergeSnapshotValue) any { + switch value.Kind { + case personMergeSnapshotNull: + return nil + case personMergeSnapshotInteger: + if value.Integer != nil { + return *value.Integer + } + case personMergeSnapshotReal: + if value.Real != nil { + return *value.Real + } + case personMergeSnapshotBoolean: + if value.Boolean != nil { + return *value.Boolean + } + case personMergeSnapshotText: + if value.Text != nil { + return *value.Text + } + case personMergeSnapshotBytes: + return value.Bytes + } + return nil +} + +type priorPersonMergeJournalRow struct { + mergeID int64 + originalRowID sql.NullInt64 + originalKey string + currentRowID sql.NullInt64 + currentKey sql.NullString + postMergeJSON sql.NullString +} + +// rebasePriorPersonMergeRowsAfterSplitTx keeps older unsplit journals pointed +// at the physical row produced by this split. Merge reconciliation performs +// the forward rebase when rows move or collapse; the inverse operation must do +// the same or a later reversal of the older merge follows a stale key. +func (s *Store) rebasePriorPersonMergeRowsAfterSplitTx( + ctx context.Context, + tx *loggedTx, + mergeID int64, + journal personSplitJournalRow, + snapshotRow personMergeSnapshotRow, + sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, + sourceUID, newUID string, + replacement bool, +) error { + spec, ok := personMergeTableRegistry[journal.tableName] + if !ok { + return fmt.Errorf("%w: unregistered split table %q", ErrPersonMergeInvalid, journal.tableName) + } + newRowID, newRowKey, exists, err := s.personSplitRestoredRowLocatorTx( + ctx, tx, spec, snapshotRow, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, + ) + if err != nil { + return err + } + var rebasedCurrent *personMergeSnapshotRow + if exists { + where, args, err := personSplitCurrentRowWhere(spec, personSplitJournalRow{ + currentRowID: newRowID, currentKey: newRowKey, + }) + if err != nil { + return err + } + currentRows, err := s.capturePersonMergeQueryTx(ctx, tx, spec, + `SELECT * FROM `+personSplitIdentifier(spec.TableName)+` WHERE `+where, + args, absorbedSnapshotID) + if err != nil { + return err + } + if len(currentRows) != 1 { + return fmt.Errorf("%w: rebased %s row is missing", + ErrPersonSplitParticipants, journal.tableName) + } + rebasedCurrent = ¤tRows[0] + } + + rows, err := tx.QueryContext(ctx, `SELECT merge_id, original_row_id, + original_row_key, current_row_id, current_row_key, post_merge_row_json + FROM person_merge_rows + WHERE merge_id <> ? AND table_name = ? AND split_id IS NULL + ORDER BY merge_id, original_row_key`, mergeID, journal.tableName) + if err != nil { + return fmt.Errorf("load prior %s split journals: %w", journal.tableName, err) + } + defer func() { _ = rows.Close() }() + prior := []priorPersonMergeJournalRow{} + for rows.Next() { + var item priorPersonMergeJournalRow + if err := rows.Scan( + &item.mergeID, &item.originalRowID, &item.originalKey, + &item.currentRowID, &item.currentKey, &item.postMergeJSON, + ); err != nil { + return fmt.Errorf("scan prior %s split journal: %w", journal.tableName, err) + } + prior = append(prior, item) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate prior %s split journals: %w", journal.tableName, err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close prior %s split journals: %w", journal.tableName, err) + } + + recreated := replacement || journal.action == "deleted_snapshot" || + (journal.action == personMergeActionDeduplicated && !personSplitJournalRowWasRetained(journal)) + for _, item := range prior { + locatorMatch := personSplitJournalLocatorsEqual( + item.currentRowID, item.currentKey, journal.currentRowID, journal.currentKey, + ) + lineageMatch, err := priorPersonMergeJournalMatchesSplitOriginal(item, spec, journal) + if err != nil { + return err + } + if (recreated && !lineageMatch) || (!recreated && !locatorMatch) { + continue + } + action := "deleted_snapshot" + var currentRowID, currentRowKey any + if exists { + action = "moved" + if newRowID.Valid { + currentRowID = newRowID.Int64 + } + if newRowKey.Valid { + currentRowKey = newRowKey.String + } + } + var rebasedPostMergeJSON sql.NullString + if !exists { + rebasedPostMergeJSON = sql.NullString{} + } else { + var err error + rebasedPostMergeJSON, _, err = rebasePersonMergePostRowReferences( + item.postMergeJSON, *rebasedCurrent, spec, map[int64]int64{ + absorbedSnapshotID: newPersonID, + sourceID: newPersonID, + }, + ) + if err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE person_merge_rows SET + action = ?, current_row_id = ?, current_row_key = ?, post_merge_row_json = ? + WHERE merge_id = ? AND table_name = ? AND original_row_key = ? + AND split_id IS NULL`, action, currentRowID, currentRowKey, rebasedPostMergeJSON, + item.mergeID, journal.tableName, item.originalKey); err != nil { + return fmt.Errorf("rebase prior %s journal after split: %w", journal.tableName, err) + } + if exists { + if err := syncPersonMergeRowPersonRefsTx( + ctx, tx, item.mergeID, journal.tableName, item.originalKey, + *rebasedCurrent, spec, + ); err != nil { + return err + } + } else if _, err := tx.ExecContext(ctx, `DELETE FROM person_merge_row_person_refs + WHERE merge_id = ? AND table_name = ? AND original_row_key = ?`, + item.mergeID, journal.tableName, item.originalKey); err != nil { + return fmt.Errorf("clear prior %s split person references: %w", journal.tableName, err) + } + } + return nil +} + +func (s *Store) personSplitTrackingRowReplacedTx( + ctx context.Context, tx *loggedTx, journal personSplitJournalRow, +) (bool, error) { + if journal.tableName != "person_tracking" || !journal.postMergeJSON.Valid { + return false, nil + } + var post personMergeSnapshotRow + if err := json.Unmarshal([]byte(journal.postMergeJSON.String), &post); err != nil { + return false, fmt.Errorf("%w: decode tracking post-merge row: %w", + ErrPersonMergeSnapshotCorrupt, err) + } + spec := personMergeTableRegistry["person_tracking"] + where, args, err := personSplitCurrentRowWhere(spec, journal) + if err != nil { + return false, err + } + current, err := s.capturePersonMergeQueryTx(ctx, tx, spec, + `SELECT * FROM person_tracking WHERE `+where, args, 0) + if err != nil { + return false, err + } + if len(current) == 0 { + return false, nil + } + if len(current) != 1 { + return false, fmt.Errorf("%w: current person_tracking row is ambiguous", + ErrPersonSplitParticipants) + } + currentColumns := personSplitSnapshotColumnsByName(current[0].Columns) + postColumns := personSplitSnapshotColumnsByName(post.Columns) + return !reflect.DeepEqual(currentColumns["tracked_at"], postColumns["tracked_at"]), nil +} + +func (s *Store) personSplitRestoredRowLocatorTx( + ctx context.Context, + tx *loggedTx, + spec personMergeTableSpec, + row personMergeSnapshotRow, + sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, + sourceUID, newUID string, +) (sql.NullInt64, sql.NullString, bool, error) { + columnsByName := make(map[string]personMergeSnapshotColumn, len(row.Columns)) + for _, column := range row.Columns { + columnsByName[column.Name] = column + } + keyNames := spec.keyColumns() + keyColumns := make([]personMergeSnapshotColumn, 0, len(keyNames)) + for _, name := range keyNames { + column, ok := columnsByName[name] + if !ok { + return sql.NullInt64{}, sql.NullString{}, false, + fmt.Errorf("%w: missing %s split key %q", ErrPersonMergeSnapshotCorrupt, spec.TableName, name) + } + value, err := s.personSplitSnapshotColumnValueTx( + ctx, tx, column, row, spec, sourceID, newPersonID, + survivorSnapshotID, absorbedSnapshotID, sourceUID, newUID, + ) + if errors.Is(err, errPersonSplitReferenceMissing) { + return sql.NullInt64{}, sql.NullString{}, false, nil + } + if err != nil { + return sql.NullInt64{}, sql.NullString{}, false, err + } + normalized, err := normalizePersonMergeSnapshotValue(value, string(column.Value.Kind)) + if err != nil { + return sql.NullInt64{}, sql.NullString{}, false, + fmt.Errorf("%w: normalize %s split key: %w", ErrPersonMergeSnapshotCorrupt, spec.TableName, err) + } + keyColumns = append(keyColumns, personMergeSnapshotColumn{Name: name, Value: normalized}) + } + encoded, err := canonicalPersonMergeSnapshotRowKey(keyColumns, keyNames) + if err != nil { + return sql.NullInt64{}, sql.NullString{}, false, err + } + where, args, err := personSplitRowKeyWhere(spec, encoded) + if err != nil { + return sql.NullInt64{}, sql.NullString{}, false, err + } + var present int + err = tx.QueryRowContext(ctx, `SELECT 1 FROM `+personSplitIdentifier(spec.TableName)+ + ` WHERE `+where+` LIMIT 1`, args...).Scan(&present) + if errors.Is(err, sql.ErrNoRows) { + return sql.NullInt64{}, sql.NullString{}, false, nil + } + if err != nil { + return sql.NullInt64{}, sql.NullString{}, false, + fmt.Errorf("locate restored %s split row: %w", spec.TableName, err) + } + rowID := sql.NullInt64{} + if len(keyColumns) == 1 && keyColumns[0].Value.Integer != nil { + rowID = sql.NullInt64{Int64: *keyColumns[0].Value.Integer, Valid: true} + } + return rowID, sql.NullString{String: encoded, Valid: true}, true, nil +} + +func personSplitJournalLocatorsEqual( + leftID sql.NullInt64, leftKey sql.NullString, + rightID sql.NullInt64, rightKey sql.NullString, +) bool { + if leftID.Valid || rightID.Valid { + return leftID.Valid && rightID.Valid && leftID.Int64 == rightID.Int64 + } + return leftKey.Valid && rightKey.Valid && leftKey.String == rightKey.String +} + +func priorPersonMergeJournalMatchesSplitOriginal( + prior priorPersonMergeJournalRow, + spec personMergeTableSpec, + current personSplitJournalRow, +) (bool, error) { + if prior.originalRowID.Valid && current.originalRowID.Valid && + prior.originalRowID.Int64 == current.originalRowID.Int64 { + return true, nil + } + if prior.originalKey == current.originalKey { + return true, nil + } + if !prior.postMergeJSON.Valid || prior.postMergeJSON.String == "" { + return false, nil + } + var post personMergeSnapshotRow + if err := json.Unmarshal([]byte(prior.postMergeJSON.String), &post); err != nil { + return false, fmt.Errorf("%w: decode prior %s post-merge row: %w", + ErrPersonMergeSnapshotCorrupt, spec.TableName, err) + } + postKey := post.RowKey + if postKey == "" { + columns := personSplitSnapshotColumnsByName(post.Columns) + keyColumns := make([]personMergeSnapshotColumn, 0, len(spec.keyColumns())) + for _, name := range spec.keyColumns() { + value, ok := columns[name] + if !ok { + return false, fmt.Errorf("%w: prior %s post-merge key", + ErrPersonMergeSnapshotCorrupt, spec.TableName) + } + keyColumns = append(keyColumns, personMergeSnapshotColumn{Name: name, Value: value}) + } + var err error + postKey, err = canonicalPersonMergeSnapshotRowKey(keyColumns, spec.keyColumns()) + if err != nil { + return false, err + } + } + return postKey == current.originalKey, nil +} + +func personSplitCurrentRowWhere( + spec personMergeTableSpec, row personSplitJournalRow, +) (string, []any, error) { + if row.currentRowID.Valid && len(spec.keyColumns()) == 1 { + return personSplitIdentifier(spec.keyColumns()[0]) + " = ?", + []any{row.currentRowID.Int64}, nil + } + if !row.currentKey.Valid { + return "", nil, fmt.Errorf("%w: current split row key is missing", ErrPersonSplitParticipants) + } + return personSplitRowKeyWhere(spec, row.currentKey.String) +} + +func (s *Store) preparePersonSplitDeduplicatedRowTx( + ctx context.Context, + tx *loggedTx, + journal personSplitJournalRow, + original personMergeSnapshotRow, +) (personMergeSnapshotRow, bool, error) { + spec, ok := personMergeTableRegistry[journal.tableName] + if !ok { + return original, false, + fmt.Errorf("%w: unregistered split table %q", ErrPersonMergeInvalid, journal.tableName) + } + where, args, err := personSplitCurrentRowWhere(spec, journal) + if err != nil { + return original, false, err + } + currentRows, err := s.capturePersonMergeQueryTx(ctx, tx, spec, + `SELECT * FROM `+personSplitIdentifier(journal.tableName)+` WHERE `+where, + args, 0) + if err != nil { + return original, false, err + } + if len(currentRows) == 0 || !journal.postMergeJSON.Valid { + return original, false, nil + } + if len(currentRows) != 1 { + return original, false, fmt.Errorf("%w: current %s row is ambiguous", + ErrPersonSplitParticipants, journal.tableName) + } + var post personMergeSnapshotRow + if err := json.Unmarshal([]byte(journal.postMergeJSON.String), &post); err != nil { + return original, false, fmt.Errorf("%w: decode deduplicated %s post-merge row: %w", + ErrPersonMergeSnapshotCorrupt, journal.tableName, err) + } + currentByName := personSplitSnapshotColumnsByName(currentRows[0].Columns) + postByName := personSplitSnapshotColumnsByName(post.Columns) + protected := make(map[string]struct{}, len(spec.keyColumns())+len(spec.PersonReferences)*2) + for _, name := range spec.keyColumns() { + protected[name] = struct{}{} + } + for _, reference := range spec.PersonReferences { + protected[reference.IDColumn] = struct{}{} + if reference.Kind == personMergeReferencePolymorphic { + protected[reference.KindColumn] = struct{}{} + } + } + for name := range protected { + currentValue, hasCurrent := currentByName[name] + postValue, hasPost := postByName[name] + if hasCurrent != hasPost || (hasCurrent && !reflect.DeepEqual(currentValue, postValue)) { + return original, false, nil + } + } + reconciled := original + reconciled.Columns = slices.Clone(original.Columns) + for index := range reconciled.Columns { + name := reconciled.Columns[index].Name + if _, fixed := protected[name]; fixed || name == "updated_at" { + continue + } + currentValue, hasCurrent := currentByName[name] + postValue, hasPost := postByName[name] + if hasCurrent && hasPost && !reflect.DeepEqual(currentValue, postValue) { + reconciled.Columns[index].Value = currentValue + } + } + return reconciled, true, nil +} + +func personSplitRowKeyWhere( + spec personMergeTableSpec, encoded string, +) (string, []any, error) { + var key []personMergeSnapshotColumn + if err := json.Unmarshal([]byte(encoded), &key); err != nil { + return "", nil, fmt.Errorf("%w: decode split row key: %w", ErrPersonMergeSnapshotCorrupt, err) + } + wanted := spec.keyColumns() + if len(key) != len(wanted) { + return "", nil, fmt.Errorf("%w: split row key shape", ErrPersonMergeSnapshotCorrupt) + } + predicates := make([]string, len(key)) + args := make([]any, len(key)) + for index, column := range key { + if column.Name != wanted[index] { + return "", nil, fmt.Errorf("%w: split row key column", ErrPersonMergeSnapshotCorrupt) + } + predicates[index] = personSplitIdentifier(column.Name) + " = ?" + args[index] = personSplitSnapshotValue(column.Value) + } + return strings.Join(predicates, " AND "), args, nil +} + +func personSplitIdentifier(value string) string { + // All callers use the closed merge-table registry or schema-derived column + // names. Quote them defensively anyway so malformed snapshot data can never + // turn validation into a process-wide panic. + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} + +func markPersonSplitJournalRowTx( + ctx context.Context, tx *loggedTx, mergeID, splitID int64, row personSplitJournalRow, +) error { + result, err := tx.ExecContext(ctx, `UPDATE person_merge_rows SET split_id = ? + WHERE merge_id = ? AND table_name = ? AND original_row_key = ? AND split_id IS NULL`, + splitID, mergeID, row.tableName, row.originalKey) + if err != nil { + return fmt.Errorf("mark split row journal: %w", err) + } + if changed, err := result.RowsAffected(); err != nil { + return fmt.Errorf("count split row journal: %w", err) + } else if changed != 1 { + return fmt.Errorf("%w: split row journal changed", ErrPersonMergeAlreadySplit) + } + return nil +} + +func (s *Store) personSplitByIdempotencyKeyTx( + ctx context.Context, tx *loggedTx, key, requestHash string, +) (*PersonSplitResult, bool, error) { + var splitID int64 + var storedHash string + var storedResult sql.NullString + var storedIdentityRevision sql.NullInt64 + err := tx.QueryRowContext(ctx, `SELECT id, request_hash, result_json, identity_revision + FROM person_splits WHERE idempotency_key = ?`, key).Scan( + &splitID, &storedHash, &storedResult, &storedIdentityRevision, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("load person split idempotency key: %w", err) + } + if storedHash != requestHash { + return nil, false, ErrPersonSplitIdempotency + } + if storedResult.Valid && storedResult.String != "" { + var result PersonSplitResult + if err := json.Unmarshal([]byte(storedResult.String), &result); err != nil { + return nil, false, fmt.Errorf("decode person split idempotency result: %w", err) + } + if !storedIdentityRevision.Valid || storedIdentityRevision.Int64 <= 0 || + result.IdentityRevision != storedIdentityRevision.Int64 { + return nil, false, errors.New("person split idempotency revision is missing or inconsistent") + } + return &result, true, nil + } + return nil, false, errors.New("person split idempotency result is missing") +} + +func (s *Store) getPersonSplitTx( + ctx context.Context, tx *loggedTx, splitID int64, +) (*PersonSplit, error) { + var split PersonSplit + err := tx.QueryRowContext(ctx, `SELECT id, merge_id, source_person_id, + new_person_id, new_person_uid, source_revision_before, + source_revision_after, actor, is_exact_reversal, created_at + FROM person_splits WHERE id = ?`, splitID).Scan( + &split.ID, &split.MergeID, &split.SourcePersonID, &split.NewPersonID, + &split.NewPersonUID, &split.SourceRevisionBefore, &split.SourceRevisionAfter, + &split.Actor, &split.ExactReversal, &split.CreatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrPersonSplitNotFound + } + if err != nil { + return nil, fmt.Errorf("get person split %d: %w", splitID, err) + } + return &split, nil +} + +// recomputePersonSplitActivityTx reclassifies materialized activity from its +// native participant evidence after bindings are divided. Messages remain +// immutable; only the derived activity links and contact aggregates change. +func (s *Store) recomputePersonSplitActivityTx( + ctx context.Context, + tx *loggedTx, + sourceID, newPersonID int64, + revisions ContactRevisions, +) error { + contactIDs, err := s.reclassifyPersonActivityTx( + ctx, tx, []int64{sourceID, newPersonID}, revisions, + ) + if err != nil { + return err + } + for _, personID := range contactIDs { + if err := s.recomputeContactStateTx(ctx, tx, personID, revisions, true); err != nil { + return err + } + } + return nil +} diff --git a/internal/store/person_splits_test.go b/internal/store/person_splits_test.go new file mode 100644 index 000000000..851bcc5d2 --- /dev/null +++ b/internal/store/person_splits_test.go @@ -0,0 +1,2810 @@ +package store_test + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/activity" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +type personSplitFixture struct { + store *store.Store + survivor, absorbed *store.Person + survivorParticipant int64 + absorbedParticipants []int64 + absorbedNameID int64 + absorbedUID string + merge *store.PersonMergeResult +} + +func newPersonSplitFixture(t *testing.T) personSplitFixture { + t.Helper() + require := require.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivorParticipant, err := st.EnsureParticipant( + "split-survivor@example.com", "Survivor", "example.com") + require.NoError(err) + firstAbsorbed, err := st.EnsureParticipant( + "split-absorbed-1@example.com", "Absorbed One", "example.com") + require.NoError(err) + secondAbsorbed, err := st.EnsureParticipant( + "split-absorbed-2@example.com", "Absorbed Two", "example.com") + require.NoError(err) + _, err = st.LinkParticipants(firstAbsorbed, secondAbsorbed) + require.NoError(err) + survivor, _, err := st.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := st.CreatePersonFromParticipant(firstAbsorbed) + require.NoError(err) + name, err := st.AddPersonNameContext(ctx, absorbed.ID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Absorbed Profile"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + _, err = st.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: survivor.ID, TargetPersonID: absorbed.ID, + TypeSlug: "friend", Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-fixture-merge", Actor: "test", + }) + require.NoError(err) + return personSplitFixture{ + store: st, survivor: &merged.Person, absorbed: absorbed, + survivorParticipant: survivorParticipant, + absorbedParticipants: []int64{firstAbsorbed, secondAbsorbed}, + absorbedNameID: name.Envelope.ID, absorbedUID: absorbed.VCardUID, merge: merged, + } +} + +func TestSplitPersonMerge_ExactReversal(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonSplitFixture(t) + ctx := context.Background() + result, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: f.absorbedParticipants, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-exact", Actor: "test", + }) + require.NoError(err) + assert.True(result.ExactReversal) + assert.Empty(result.UnrestoredRows) + assert.True(result.Split.ExactReversal) + assert.Equal("retired_uid_alias_retargeted", result.UIDAliasDisposition) + assert.Empty(result.AmbiguousRows) + assert.NotEqual(f.absorbedUID, result.NewPerson.VCardUID) + assert.Equal([]int64{f.survivorParticipant}, result.SourcePerson.ParticipantIDs) + assert.Equal(f.absorbedParticipants, result.NewPerson.ParticipantIDs) + cluster, err := f.store.ClusterMembers(f.absorbedParticipants[0]) + require.NoError(err) + assert.Equal(f.absorbedParticipants, cluster) + + profile, err := f.store.GetPersonProfileContext(ctx, result.NewPerson.ID) + require.NoError(err) + require.Len(profile.Names, 1) + assert.Equal(f.absorbedNameID, profile.Names[0].Envelope.ID) + relationships, err := f.store.ListPersonRelationshipsContext( + ctx, result.NewPerson.ID, store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(relationships, 1) + assert.Equal(result.SourcePerson.ID, relationships[0].CounterpartPersonID) + alias, err := f.store.ResolveRetiredPersonUIDContext(ctx, f.absorbedUID) + require.NoError(err) + require.NotNil(alias.SurvivingPersonID) + assert.Equal(result.NewPerson.ID, *alias.SurvivingPersonID) +} + +func TestSplitPersonMerge_ZeroParticipantProfile(t *testing.T) { + tests := []struct { + name string + cardDAVIsSurvivor bool + wantResourceOnSource bool + }{ + {name: "absorbed", cardDAVIsSurvivor: false, wantResourceOnSource: false}, + {name: "survivor", cardDAVIsSurvivor: true, wantResourceOnSource: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := t.Context() + st, account, book := newCardDAVResourceStore(t) + remote := remoteResource( + book.CanonicalURL+test.name+".vcf", + "zero-participant-"+test.name, + "CardDAV "+test.name, + test.name+"@example.test", + `"one"`, + ) + _, err := st.ApplyCardDAVSyncPlanContext(ctx, store.CardDAVSyncPlan{ + AddressBookID: book.ID, ConnectionGeneration: account.ConnectionGeneration, + SyncRevision: book.SyncRevision, Upserts: []store.CardDAVRemoteResource{remote}, + }) + require.NoError(err) + resource, err := st.GetCardDAVResourceContext(ctx, book.ID, remote.Href) + require.NoError(err) + require.NotNil(resource.PersonID) + cardDAVPerson, err := st.GetPersonContext(ctx, *resource.PersonID) + require.NoError(err) + require.Empty(cardDAVPerson.ParticipantIDs) + + localPerson := mustPromotedPerson(t, st, + "zero-participant-local-"+test.name+"@example.test", "Local "+test.name) + survivor, absorbed := localPerson, cardDAVPerson + if test.cardDAVIsSurvivor { + survivor, absorbed = cardDAVPerson, localPerson + } + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "zero-participant-merge-" + test.name, + Actor: "test", + }) + require.NoError(err) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "zero-participant-split-" + test.name, + Actor: "test", + }) + require.NoError(err) + assert.True(split.ExactReversal) + assert.Equal(survivor.ParticipantIDs, split.SourcePerson.ParticipantIDs) + assert.Equal(absorbed.ParticipantIDs, split.NewPerson.ParticipantIDs) + assert.Equal(survivor.DisplayName, split.SourcePerson.DisplayName) + assert.Equal(absorbed.DisplayName, split.NewPerson.DisplayName) + + resource, err = st.GetCardDAVResourceContext(ctx, book.ID, remote.Href) + require.NoError(err) + require.NotNil(resource.PersonID) + if test.wantResourceOnSource { + assert.Equal(split.SourcePerson.ID, *resource.PersonID) + } else { + assert.Equal(split.NewPerson.ID, *resource.PersonID) + } + }) + } +} + +func TestSplitPersonMerge_ExactReversalIncludesLaterAbsorbedAlias(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonSplitFixture(t) + ctx := t.Context() + alias, err := f.store.EnsureParticipant( + "split-later-absorbed-alias@example.com", "Later Absorbed Alias", "example.com") + require.NoError(err) + _, err = f.store.LinkParticipants( + f.survivorParticipant, f.absorbedParticipants[0]) + require.NoError(err) + _, err = f.store.LinkParticipants(f.absorbedParticipants[0], alias) + require.NoError(err) + detail, err := f.store.GetPersonMergeContext(ctx, f.merge.Merge.ID) + require.NoError(err) + var aliasLineage *store.PersonMergeParticipant + for index := range detail.Participants { + if detail.Participants[index].ParticipantID == alias { + aliasLineage = &detail.Participants[index] + break + } + } + require.NotNil(aliasLineage) + assert.Equal("absorbed", aliasLineage.OriginSide) + current, err := f.store.GetPersonContext(ctx, f.survivor.ID) + require.NoError(err) + selected := append(append([]int64{}, f.absorbedParticipants...), alias) + + result, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: selected, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "split-exact-later-absorbed-alias", Actor: "test", + }) + require.NoError(err) + assert.True(result.ExactReversal) + assert.Equal([]int64{f.survivorParticipant}, result.SourcePerson.ParticipantIDs) + assert.ElementsMatch(selected, result.NewPerson.ParticipantIDs) + cluster, err := f.store.ClusterMembers(alias) + require.NoError(err) + assert.ElementsMatch(selected, cluster) +} + +func TestSplitPersonMerge_ExactReversalRestoresIdentityCandidates(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-candidate-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-candidate-absorbed@example.com", "Absorbed") + other := mustPromotedPerson(t, st, "split-candidate-other@example.com", "Other") + absorbedSource, err := st.GetOrCreateSource("gmail", "split-candidates-absorbed") + require.NoError(err) + survivorSource, err := st.GetOrCreateSource("gmail", "split-candidates-survivor") + require.NoError(err) + collapsedSource, err := st.GetOrCreateSource("gmail", "split-candidates-collapsed") + require.NoError(err) + collapsedService, _, err := st.EnsureCommunicationServiceContext(ctx, + store.CommunicationServiceInput{ + Slug: "split-candidate-service", DisplayLabel: "Split Candidate Service", + ScopePolicy: store.ScopePolicyNone, Normalization: store.NormalizationNone, + NormalizationVersion: 1, + }) + require.NoError(err) + input := func(personID, sourceID int64) store.IdentityMatchCandidateInput { + return store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchPerson, LeftID: personID, + RightKind: store.IdentityMatchPerson, RightID: other.ID, + Basis: store.IdentityMatchDisplayName, NormalizedValue: new("same person"), + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceUser, + SourceID: &sourceID, + } + } + absorbedCandidate, _, err := st.UpsertIdentityMatchCandidateContext( + ctx, input(absorbed.ID, absorbedSource.ID)) + require.NoError(err) + survivorCandidate, _, err := st.UpsertIdentityMatchCandidateContext( + ctx, input(survivor.ID, survivorSource.ID)) + require.NoError(err) + selfCandidate, _, err := st.UpsertIdentityMatchCandidateContext(ctx, + store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchPerson, LeftID: survivor.ID, + RightKind: store.IdentityMatchPerson, RightID: absorbed.ID, + Basis: store.IdentityMatchDisplayName, State: store.IdentityMatchStateCandidate, + ServiceSlug: &collapsedService.Slug, + Source: store.ProvenanceUser, SourceID: &collapsedSource.ID, + }) + require.NoError(err) + priorRedirectID := absorbedCandidate.ID + 10_000 + _, err = st.DB().ExecContext(ctx, st.Rebind(`INSERT INTO identity_match_candidate_redirects + (retired_candidate_id, surviving_candidate_id, endpoints_collapsed) + VALUES (?, ?, FALSE)`), priorRedirectID, absorbedCandidate.ID) + require.NoError(err) + + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-candidate-merge", Actor: "test", + }) + require.NoError(err) + var repointedSourceRows int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM person_merge_rows + WHERE merge_id = ? AND table_name = 'identity_match_candidate_sources' + AND action = 'repointed'`), merged.Merge.ID).Scan(&repointedSourceRows)) + assert.Equal(1, repointedSourceRows) + require.NoError(st.RemoveSource(collapsedSource.ID)) + _, err = st.DB().ExecContext(ctx, st.Rebind( + `DELETE FROM communication_services WHERE id = ?`), collapsedService.ID) + require.NoError(err) + current, err := st.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + result, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "split-candidate-exact", Actor: "test", + }) + require.NoError(err) + assert.False(result.ExactReversal) + assert.NotEmpty(result.UnrestoredRows) + + for _, candidateID := range []int64{absorbedCandidate.ID, selfCandidate.ID} { + var candidateCount, redirectCount int + require.NoError(st.DB().QueryRowContext(ctx, + st.Rebind(`SELECT COUNT(*) FROM identity_match_candidates WHERE id = ?`), + candidateID).Scan(&candidateCount)) + require.NoError(st.DB().QueryRowContext(ctx, + st.Rebind(`SELECT COUNT(*) FROM identity_match_candidate_redirects WHERE retired_candidate_id = ?`), + candidateID).Scan(&redirectCount)) + assert.Equal(1, candidateCount) + assert.Zero(redirectCount) + } + var priorRedirectTarget int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT surviving_candidate_id + FROM identity_match_candidate_redirects WHERE retired_candidate_id = ?`), + priorRedirectID).Scan(&priorRedirectTarget)) + assert.Equal(absorbedCandidate.ID, priorRedirectTarget) + var restoredServiceID sql.NullInt64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT service_id + FROM identity_match_candidates WHERE id = ?`), selfCandidate.ID).Scan(&restoredServiceID)) + assert.False(restoredServiceID.Valid) + _, err = st.GetIdentityMatchCandidateContext(ctx, survivorCandidate.ID) + require.NoError(err) + for _, want := range []struct { + candidateID, sourceID int64 + present int + }{ + {absorbedCandidate.ID, absorbedSource.ID, 1}, + {absorbedCandidate.ID, survivorSource.ID, 0}, + {survivorCandidate.ID, absorbedSource.ID, 0}, + {survivorCandidate.ID, survivorSource.ID, 1}, + } { + var count int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM identity_match_candidate_sources + WHERE candidate_id = ? AND source_id = ?`), + want.candidateID, want.sourceID).Scan(&count)) + assert.Equal(want.present, count) + } + var collapsedSourceRows int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM identity_match_candidate_sources + WHERE candidate_id = ? AND source_id = ?`), + selfCandidate.ID, collapsedSource.ID).Scan(&collapsedSourceRows)) + assert.Zero(collapsedSourceRows) +} + +func TestSplitPersonMerge_ExactReversalKeepsExternalMergeLineage(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-lineage-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-lineage-absorbed@example.com", "Absorbed") + externalSurvivor := mustPromotedPerson(t, st, + "split-lineage-external-survivor@example.com", "External Survivor") + externalAbsorbed := mustPromotedPerson(t, st, + "split-lineage-external-absorbed@example.com", "External Absorbed") + relationshipIDs := make([]int64, 0, 2) + for _, personID := range []int64{survivor.ID, absorbed.ID} { + relationship, err := st.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: personID, TargetPersonID: externalAbsorbed.ID, + TypeSlug: "friend", Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + relationshipIDs = append(relationshipIDs, relationship.ID) + } + survivor, err := st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-external-lineage-profile-merge", Actor: "test", + }) + require.NoError(err) + externalSurvivor, err = st.GetPersonContext(ctx, externalSurvivor.ID) + require.NoError(err) + externalAbsorbed, err = st.GetPersonContext(ctx, externalAbsorbed.ID) + require.NoError(err) + externalMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: externalSurvivor.ID, AbsorbedID: externalAbsorbed.ID, + ExpectedSurvivorRevision: externalSurvivor.Revision, + ExpectedAbsorbedRevision: externalAbsorbed.Revision, + IdempotencyKey: "split-external-lineage-target-merge", Actor: "test", + }) + require.NoError(err) + current, err := st.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "split-external-lineage-exact", Actor: "test", + }) + require.NoError(err) + require.True(split.ExactReversal) + for _, personID := range []int64{split.SourcePerson.ID, split.NewPerson.ID} { + relationships, listErr := st.ListPersonRelationshipsContext( + ctx, personID, store.PersonRelationshipListOptions{}) + require.NoError(listErr) + require.Len(relationships, 1) + assert.Equal(externalMerge.Person.ID, relationships[0].CounterpartPersonID) + } + for _, relationshipID := range relationshipIDs { + var sourcePersonID, targetPersonID int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT source_person_id, + target_person_id FROM person_relationships WHERE id = ?`), relationshipID). + Scan(&sourcePersonID, &targetPersonID)) + assert.NotEqual(externalAbsorbed.ID, sourcePersonID) + assert.NotEqual(externalAbsorbed.ID, targetPersonID) + assert.Contains([]int64{sourcePersonID, targetPersonID}, externalMerge.Person.ID) + } +} + +func TestSplitPersonMerge_ExactReversalSkipsUnsupportedGeneratedCandidate(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, + "split-generated-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, + "split-generated-absorbed@example.com", "Absorbed") + other := mustPromotedPerson(t, st, + "split-generated-other@example.com", "Other") + absorbedSource, err := st.GetOrCreateSource("gmail", "split-generated-absorbed") + require.NoError(err) + survivorSource, err := st.GetOrCreateSource("gmail", "split-generated-survivor") + require.NoError(err) + input := func(personID, sourceID int64) store.IdentityMatchCandidateInput { + return store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchPerson, LeftID: personID, + RightKind: store.IdentityMatchPerson, RightID: other.ID, + Basis: store.IdentityMatchDisplayName, NormalizedValue: new("same generated person"), + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceArchiveObservation, + SourceID: &sourceID, + } + } + absorbedCandidate, _, err := st.UpsertIdentityMatchCandidateContext( + ctx, input(absorbed.ID, absorbedSource.ID)) + require.NoError(err) + survivorCandidate, _, err := st.UpsertIdentityMatchCandidateContext( + ctx, input(survivor.ID, survivorSource.ID)) + require.NoError(err) + evidence, err := st.AddIdentityMatchEvidenceContext(ctx, absorbedCandidate.ID, + store.IdentityMatchEvidenceInput{ + EvidenceKind: "shared_name", Source: store.ProvenanceArchiveObservation, + SourceID: &absorbedSource.ID, + }) + require.NoError(err) + + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-generated-merge", Actor: "test", + }) + require.NoError(err) + require.NoError(st.RemoveSource(absorbedSource.ID)) + current, err := st.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "split-generated-exact", Actor: "test", + }) + require.NoError(err) + assert.False(split.ExactReversal) + assert.NotEmpty(split.UnrestoredRows) + for _, row := range []struct { + table string + id int64 + }{ + {table: "identity_match_candidates", id: absorbedCandidate.ID}, + {table: "identity_match_evidence", id: evidence.ID}, + } { + var count int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind( + `SELECT COUNT(*) FROM `+row.table+` WHERE id = ?`), row.id).Scan(&count)) + assert.Zero(count) + } + _, err = st.GetIdentityMatchCandidateContext(ctx, survivorCandidate.ID) + require.NoError(err) +} + +func TestSplitPersonMerge_ExactReversalSkipsIndividuallyUnsupportedEvidence(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, + "split-evidence-support-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, + "split-evidence-support-absorbed@example.com", "Absorbed") + other := mustPromotedPerson(t, st, + "split-evidence-support-other@example.com", "Other") + removedSource, err := st.GetOrCreateSource("gmail", "split-evidence-support-removed") + require.NoError(err) + remainingSource, err := st.GetOrCreateSource("gmail", "split-evidence-support-remaining") + require.NoError(err) + input := func(personID, sourceID int64) store.IdentityMatchCandidateInput { + return store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchPerson, LeftID: personID, + RightKind: store.IdentityMatchPerson, RightID: other.ID, + Basis: store.IdentityMatchDisplayName, NormalizedValue: new("supported generated person"), + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceArchiveObservation, + SourceID: &sourceID, + } + } + absorbedCandidate, _, err := st.UpsertIdentityMatchCandidateContext( + ctx, input(absorbed.ID, removedSource.ID)) + require.NoError(err) + require.NoError(st.AttachIdentityMatchCandidateSourceContext( + ctx, absorbedCandidate.ID, remainingSource.ID)) + _, _, err = st.UpsertIdentityMatchCandidateContext( + ctx, input(survivor.ID, remainingSource.ID)) + require.NoError(err) + evidence, err := st.AddIdentityMatchEvidenceContext(ctx, absorbedCandidate.ID, + store.IdentityMatchEvidenceInput{ + EvidenceKind: "shared_name", Source: store.ProvenanceArchiveObservation, + SourceID: &removedSource.ID, + }) + require.NoError(err) + + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-evidence-support-merge", Actor: "test", + }) + require.NoError(err) + require.NoError(st.RemoveSource(removedSource.ID)) + current, err := st.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "split-evidence-support-exact", Actor: "test", + }) + require.NoError(err) + assert.False(split.ExactReversal) + assert.NotEmpty(split.UnrestoredRows) + _, err = st.GetIdentityMatchCandidateContext(ctx, absorbedCandidate.ID) + require.NoError(err, "the candidate retains independent source support") + var evidenceCount, supportCount int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind( + `SELECT COUNT(*) FROM identity_match_evidence WHERE id = ?`), + evidence.ID).Scan(&evidenceCount)) + assert.Zero(evidenceCount) + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM identity_match_candidate_sources WHERE candidate_id = ? AND source_id = ?`), + absorbedCandidate.ID, remainingSource.ID).Scan(&supportCount)) + assert.Equal(1, supportCount) +} + +func TestSplitPersonMerge_ExactReversalSkipsUnsupportedEvidenceForCollapsedCandidate( + t *testing.T, +) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, + "split-collapsed-evidence-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, + "split-collapsed-evidence-absorbed@example.com", "Absorbed") + removedSource, err := st.GetOrCreateSource("gmail", "split-collapsed-evidence-removed") + require.NoError(err) + remainingSource, err := st.GetOrCreateSource("gmail", "split-collapsed-evidence-remaining") + require.NoError(err) + candidate, _, err := st.UpsertIdentityMatchCandidateContext(ctx, + store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchPerson, LeftID: survivor.ID, + RightKind: store.IdentityMatchPerson, RightID: absorbed.ID, + Basis: store.IdentityMatchDisplayName, NormalizedValue: new("collapsed generated person"), + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceArchiveObservation, + SourceID: &removedSource.ID, + }) + require.NoError(err) + require.NoError(st.AttachIdentityMatchCandidateSourceContext( + ctx, candidate.ID, remainingSource.ID)) + evidence, err := st.AddIdentityMatchEvidenceContext(ctx, candidate.ID, + store.IdentityMatchEvidenceInput{ + EvidenceKind: "shared_name", Source: store.ProvenanceArchiveObservation, + SourceID: &removedSource.ID, + }) + require.NoError(err) + + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-collapsed-evidence-merge", Actor: "test", + }) + require.NoError(err) + _, err = st.GetIdentityMatchCandidateContext(ctx, candidate.ID) + require.ErrorIs(err, store.ErrIdentityMatchNotFound) + require.NoError(st.RemoveSource(removedSource.ID)) + current, err := st.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "split-collapsed-evidence-exact", Actor: "test", + }) + require.NoError(err) + assert.False(split.ExactReversal) + assert.NotEmpty(split.UnrestoredRows) + _, err = st.GetIdentityMatchCandidateContext(ctx, candidate.ID) + require.NoError(err, "independent candidate support survives endpoint restoration") + var evidenceCount, supportCount int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind( + `SELECT COUNT(*) FROM identity_match_evidence WHERE id = ?`), + evidence.ID).Scan(&evidenceCount)) + assert.Zero(evidenceCount) + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM identity_match_candidate_sources WHERE candidate_id = ? AND source_id = ?`), + candidate.ID, remainingSource.ID).Scan(&supportCount)) + assert.Equal(1, supportCount) +} + +func TestSplitPersonMerge_CompletedExactSplitReportsAlreadySplit(t *testing.T) { + require := require.New(t) + f := newPersonSplitFixture(t) + ctx := context.Background() + result, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: f.absorbedParticipants, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-exact-first", Actor: "test", + }) + require.NoError(err) + + _, err = f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: result.SourcePerson.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: f.absorbedParticipants, + ExpectedSourceRevision: result.SourcePerson.Revision, + IdempotencyKey: "split-exact-again", Actor: "test", + }) + require.ErrorIs(err, store.ErrPersonMergeAlreadySplit) +} + +func TestSplitPersonMerge_ExactReversalAfterChainedMergeRebasesCompositeKeyJournal(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + first := mustPromotedPerson(t, st, "split-chain-first@example.com", "First") + second := mustPromotedPerson(t, st, "split-chain-second@example.com", "Second") + third := mustPromotedPerson(t, st, "split-chain-third@example.com", "Third") + entry, err := st.CreateDailyNoteEntryContext(ctx, store.DailyNoteEntryInput{ + LocalDate: "2026-08-19", Body: "belongs to second", Author: "test", + PersonIDs: []int64{second.ID}, + }) + require.NoError(err) + + firstMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: first.ID, AbsorbedID: second.ID, + ExpectedSurvivorRevision: first.Revision, + ExpectedAbsorbedRevision: second.Revision, + IdempotencyKey: "split-chain-first-merge", Actor: "test", + }) + require.NoError(err) + third, err = st.GetPersonContext(ctx, third.ID) + require.NoError(err) + secondMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: third.ID, AbsorbedID: firstMerge.Person.ID, + ExpectedSurvivorRevision: third.Revision, + ExpectedAbsorbedRevision: firstMerge.Person.Revision, + IdempotencyKey: "split-chain-second-merge", Actor: "test", + }) + require.NoError(err) + + result, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: secondMerge.Person.ID, MergeID: firstMerge.Merge.ID, + ParticipantIDs: second.ParticipantIDs, + ExpectedSourceRevision: secondMerge.Person.Revision, + IdempotencyKey: "split-chain-exact", Actor: "test", + }) + require.NoError(err) + assert.True(result.ExactReversal) + + var newPersonLinks, sourceLinks int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM daily_note_entry_persons WHERE entry_id = ? AND person_id = ?`), + entry.ID, result.NewPerson.ID).Scan(&newPersonLinks)) + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM daily_note_entry_persons WHERE entry_id = ? AND person_id = ?`), + entry.ID, result.SourcePerson.ID).Scan(&sourceLinks)) + assert.Equal(1, newPersonLinks) + assert.Zero(sourceLinks) +} + +func TestSplitPersonMerge_ExactReversalAfterChainedMergeRebasesEmploymentJournal(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + first := mustPromotedPerson(t, st, "split-chain-job-first@example.com", "First") + second := mustPromotedPerson(t, st, "split-chain-job-second@example.com", "Second") + third := mustPromotedPerson(t, st, "split-chain-job-third@example.com", "Third") + organization := mustOrganization(t, st, "Split Chained Employment") + secondEmployment, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: second.ID, OrganizationID: organization.ID, + Title: new("Engineer"), Source: store.ProvenanceUser, + }) + require.NoError(err) + + firstMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: first.ID, AbsorbedID: second.ID, + ExpectedSurvivorRevision: first.Revision, + ExpectedAbsorbedRevision: second.Revision, + IdempotencyKey: "split-chain-job-first-merge", Actor: "test", + }) + require.NoError(err) + third, err = st.GetPersonContext(ctx, third.ID) + require.NoError(err) + secondMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: third.ID, AbsorbedID: firstMerge.Person.ID, + ExpectedSurvivorRevision: third.Revision, + ExpectedAbsorbedRevision: firstMerge.Person.Revision, + IdempotencyKey: "split-chain-job-second-merge", Actor: "test", + }) + require.NoError(err) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: secondMerge.Person.ID, MergeID: firstMerge.Merge.ID, + ParticipantIDs: second.ParticipantIDs, + ExpectedSourceRevision: secondMerge.Person.Revision, + IdempotencyKey: "split-chain-job-first-split", Actor: "test", + }) + require.NoError(err) + assert.True(split.ExactReversal) + newEmployments, err := st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: split.NewPerson.ID, + }) + require.NoError(err) + require.Len(newEmployments, 1) + assert.Equal(secondEmployment.ID, newEmployments[0].ID) + sourceEmployments, err := st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: split.SourcePerson.ID, + }) + require.NoError(err) + assert.Empty(sourceEmployments) +} + +func TestSplitPersonMerge_LaterSplitRebasesEarlierCompositeKeyJournal(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + first := mustPromotedPerson(t, st, "split-rebase-first@example.com", "First") + second := mustPromotedPerson(t, st, "split-rebase-second@example.com", "Second") + third := mustPromotedPerson(t, st, "split-rebase-third@example.com", "Third") + entry, err := st.CreateDailyNoteEntryContext(ctx, store.DailyNoteEntryInput{ + LocalDate: "2026-08-20", Body: "belongs to second", Author: "test", + PersonIDs: []int64{second.ID}, + }) + require.NoError(err) + + firstMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: first.ID, AbsorbedID: second.ID, + ExpectedSurvivorRevision: first.Revision, + ExpectedAbsorbedRevision: second.Revision, + IdempotencyKey: "split-rebase-first-merge", Actor: "test", + }) + require.NoError(err) + third, err = st.GetPersonContext(ctx, third.ID) + require.NoError(err) + secondMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: third.ID, AbsorbedID: firstMerge.Person.ID, + ExpectedSurvivorRevision: third.Revision, + ExpectedAbsorbedRevision: firstMerge.Person.Revision, + IdempotencyKey: "split-rebase-second-merge", Actor: "test", + }) + require.NoError(err) + + laterSplit, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: secondMerge.Person.ID, MergeID: secondMerge.Merge.ID, + ParticipantIDs: firstMerge.Person.ParticipantIDs, + ExpectedSourceRevision: secondMerge.Person.Revision, + IdempotencyKey: "split-rebase-second-split", Actor: "test", + }) + require.NoError(err) + assert.True(laterSplit.ExactReversal) + + earlierSplit, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: laterSplit.NewPerson.ID, MergeID: firstMerge.Merge.ID, + ParticipantIDs: second.ParticipantIDs, + ExpectedSourceRevision: laterSplit.NewPerson.Revision, + IdempotencyKey: "split-rebase-first-split", Actor: "test", + }) + require.NoError(err) + assert.True(earlierSplit.ExactReversal) + + var newPersonLinks, sourceLinks int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM daily_note_entry_persons WHERE entry_id = ? AND person_id = ?`), + entry.ID, earlierSplit.NewPerson.ID).Scan(&newPersonLinks)) + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT COUNT(*) + FROM daily_note_entry_persons WHERE entry_id = ? AND person_id = ?`), + entry.ID, earlierSplit.SourcePerson.ID).Scan(&sourceLinks)) + assert.Equal(1, newPersonLinks) + assert.Zero(sourceLinks) +} + +func TestSplitPersonMerge_LaterSplitRebasesEarlierDeduplicatedRowJournal(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + first := mustPromotedPerson(t, st, "split-dedup-first@example.com", "First") + second := mustPromotedPerson(t, st, "split-dedup-second@example.com", "Second") + third := mustPromotedPerson(t, st, "split-dedup-third@example.com", "Third") + organization := mustOrganization(t, st, "Split Dedup Rebase Organization") + secondEmployment, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: second.ID, OrganizationID: organization.ID, + Title: new("Engineer"), Source: store.ProvenanceUser, + }) + require.NoError(err) + thirdEmployment, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: third.ID, OrganizationID: organization.ID, + Title: new("Engineer"), Source: store.ProvenanceUser, + }) + require.NoError(err) + + firstMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: first.ID, AbsorbedID: second.ID, + ExpectedSurvivorRevision: first.Revision, + ExpectedAbsorbedRevision: second.Revision, + IdempotencyKey: "split-dedup-first-merge", Actor: "test", + }) + require.NoError(err) + third, err = st.GetPersonContext(ctx, third.ID) + require.NoError(err) + secondMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: third.ID, AbsorbedID: firstMerge.Person.ID, + ExpectedSurvivorRevision: third.Revision, + ExpectedAbsorbedRevision: firstMerge.Person.Revision, + IdempotencyKey: "split-dedup-second-merge", Actor: "test", + }) + require.NoError(err) + + laterSplit, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: secondMerge.Person.ID, MergeID: secondMerge.Merge.ID, + ParticipantIDs: firstMerge.Person.ParticipantIDs, + ExpectedSourceRevision: secondMerge.Person.Revision, + IdempotencyKey: "split-dedup-second-split", Actor: "test", + }) + require.NoError(err) + earlierSplit, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: laterSplit.NewPerson.ID, MergeID: firstMerge.Merge.ID, + ParticipantIDs: second.ParticipantIDs, + ExpectedSourceRevision: laterSplit.NewPerson.Revision, + IdempotencyKey: "split-dedup-first-split", Actor: "test", + }) + require.NoError(err) + + thirdEmployments, err := st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: laterSplit.SourcePerson.ID, + }) + require.NoError(err) + require.Len(thirdEmployments, 1) + assert.Equal(thirdEmployment.ID, thirdEmployments[0].ID) + secondEmployments, err := st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: earlierSplit.NewPerson.ID, + }) + require.NoError(err) + require.Len(secondEmployments, 1) + assert.Equal(secondEmployment.ID, secondEmployments[0].ID) + firstEmployments, err := st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: earlierSplit.SourcePerson.ID, + }) + require.NoError(err) + assert.Empty(firstEmployments) +} + +func TestSplitPersonMerge_LaterMergeIsPartialAfterEarlierSurvivorLineageSplit(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + first := mustPromotedPerson(t, st, "split-nested-first@example.com", "First") + second := mustPromotedPerson(t, st, "split-nested-second@example.com", "Second") + third := mustPromotedPerson(t, st, "split-nested-third@example.com", "Third") + secondName, err := st.AddPersonNameContext(ctx, second.ID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Nested Second"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + thirdName, err := st.AddPersonNameContext(ctx, third.ID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Nested Third"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + thirdUID := third.VCardUID + first, err = st.GetPersonContext(ctx, first.ID) + require.NoError(err) + second, err = st.GetPersonContext(ctx, second.ID) + require.NoError(err) + firstMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: first.ID, AbsorbedID: second.ID, + ExpectedSurvivorRevision: first.Revision, + ExpectedAbsorbedRevision: second.Revision, + IdempotencyKey: "split-nested-first-merge", Actor: "test", + }) + require.NoError(err) + third, err = st.GetPersonContext(ctx, third.ID) + require.NoError(err) + secondMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: firstMerge.Person.ID, AbsorbedID: third.ID, + ExpectedSurvivorRevision: firstMerge.Person.Revision, + ExpectedAbsorbedRevision: third.Revision, + IdempotencyKey: "split-nested-second-merge", Actor: "test", + }) + require.NoError(err) + + earlierSplit, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: secondMerge.Person.ID, MergeID: firstMerge.Merge.ID, + ParticipantIDs: second.ParticipantIDs, + ExpectedSourceRevision: secondMerge.Person.Revision, + IdempotencyKey: "split-nested-first-split", Actor: "test", + }) + require.NoError(err) + assert.True(earlierSplit.ExactReversal) + + laterSplit, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: earlierSplit.SourcePerson.ID, MergeID: secondMerge.Merge.ID, + ParticipantIDs: third.ParticipantIDs, + ExpectedSourceRevision: earlierSplit.SourcePerson.Revision, + IdempotencyKey: "split-nested-second-split", Actor: "test", + }) + require.NoError(err) + assert.False(laterSplit.ExactReversal) + assert.Equal("retired_uid_alias_retargeted", laterSplit.UIDAliasDisposition) + assert.Contains(laterSplit.NewPerson.ParticipantIDs, third.ParticipantIDs[0]) + thirdProfile, err := st.GetPersonProfileContext(ctx, laterSplit.NewPerson.ID) + require.NoError(err) + require.Len(thirdProfile.Names, 1) + assert.Equal(thirdName.Envelope.ID, thirdProfile.Names[0].Envelope.ID) + alias, err := st.ResolveRetiredPersonUIDContext(ctx, thirdUID) + require.NoError(err) + require.NotNil(alias.SurvivingPersonID) + assert.Equal(laterSplit.NewPerson.ID, *alias.SurvivingPersonID) + var nameOwner int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT person_id + FROM person_names WHERE id = ?`), secondName.Envelope.ID).Scan(&nameOwner)) + assert.Equal(earlierSplit.NewPerson.ID, nameOwner) +} + +func TestSplitPersonMerge_ChainedPartialSplitsReleasePersonDeletion(t *testing.T) { + require := require.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + first := mustPromotedPerson(t, st, "split-chain-first@example.com", "First") + second := mustPromotedPerson(t, st, "split-chain-second@example.com", "Second") + third := mustPromotedPerson(t, st, "split-chain-third@example.com", "Third") + secondName, err := st.AddPersonNameContext(ctx, second.ID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Second Profile"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + second, err = st.GetPersonContext(ctx, second.ID) + require.NoError(err) + + firstMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: first.ID, AbsorbedID: second.ID, + ExpectedSurvivorRevision: first.Revision, + ExpectedAbsorbedRevision: second.Revision, + IdempotencyKey: "split-chain-first-merge", Actor: "test", + }) + require.NoError(err) + secondMerge, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: third.ID, AbsorbedID: firstMerge.Person.ID, + ExpectedSurvivorRevision: third.Revision, + ExpectedAbsorbedRevision: firstMerge.Person.Revision, + IdempotencyKey: "split-chain-second-merge", Actor: "test", + }) + require.NoError(err) + + _, err = st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: secondMerge.Person.ID, MergeID: secondMerge.Merge.ID, + ParticipantIDs: first.ParticipantIDs, + ExpectedSourceRevision: secondMerge.Person.Revision, + IdempotencyKey: "split-chain-survivor-first", Actor: "test", + }) + require.ErrorIs(err, store.ErrPersonSplitParticipants) + + secondSplit, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: secondMerge.Person.ID, MergeID: secondMerge.Merge.ID, + ParticipantIDs: second.ParticipantIDs, + ExpectedSourceRevision: secondMerge.Person.Revision, + IdempotencyKey: "split-chain-second-participant", Actor: "test", + }) + require.NoError(err) + secondProfile, err := st.GetPersonProfileContext(ctx, secondSplit.NewPerson.ID) + require.NoError(err) + require.Len(secondProfile.Names, 1) + assert.Equal(t, secondName.Envelope.ID, secondProfile.Names[0].Envelope.ID) + firstSplit, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: secondSplit.SourcePerson.ID, MergeID: secondMerge.Merge.ID, + ParticipantIDs: first.ParticipantIDs, + ExpectedSourceRevision: secondSplit.SourcePerson.Revision, + IdempotencyKey: "split-chain-first-participant", Actor: "test", + }) + require.NoError(err) + require.NoError(st.DeletePersonContext( + ctx, firstSplit.SourcePerson.ID, firstSplit.SourcePerson.Revision, + )) +} + +func TestSplitPersonMerge_ExactReversalPreservesPostMergeRowEdits(t *testing.T) { + require := require.New(t) + f := newPersonSplitFixture(t) + ctx := context.Background() + _, err := f.store.DB().ExecContext(ctx, + f.store.Rebind(`UPDATE person_names SET formatted = ? WHERE id = ?`), + "Curated After Merge", f.absorbedNameID) + require.NoError(err) + result, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: f.absorbedParticipants, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-exact-edited", Actor: "test", + }) + require.NoError(err) + profile, err := f.store.GetPersonProfileContext(ctx, result.NewPerson.ID) + require.NoError(err) + require.Len(profile.Names, 1) + require.NotNil(profile.Names[0].Formatted) + assert.Equal(t, "Curated After Merge", *profile.Names[0].Formatted) +} + +func TestSplitPersonMerge_ExactReversalPreservesPostMergeRowDeletion(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-delete-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-delete-absorbed@example.com", "Absorbed") + organization := mustOrganization(t, st, "Split Delete Organization") + _, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: absorbed.ID, OrganizationID: organization.ID, + Title: new("Engineer"), Source: store.ProvenanceUser, + }) + require.NoError(err) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-delete-merge", Actor: "test", + }) + require.NoError(err) + employments, err := st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: merged.Person.ID, + }) + require.NoError(err) + require.Len(employments, 1) + require.NoError(st.DeleteEmploymentContext(ctx, employments[0].ID, employments[0].Revision)) + current, err := st.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + merged.Person = *current + + result, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-delete-exact", Actor: "test", + }) + require.NoError(err) + assert.True(result.ExactReversal) + for _, personID := range []int64{result.SourcePerson.ID, result.NewPerson.ID} { + employments, err = st.ListEmploymentsContext(ctx, store.EmploymentFilter{PersonID: personID}) + require.NoError(err) + assert.Empty(employments) + } +} + +func TestSplitPersonMerge_ExactReversalRestoresDeduplicatedRows(t *testing.T) { + require := require.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-job-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-job-absorbed@example.com", "Absorbed") + organization := mustOrganization(t, st, "Split Shared Employer") + _, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: survivor.ID, OrganizationID: organization.ID, + Title: new("Engineer"), Source: store.ProvenanceUser, + }) + require.NoError(err) + absorbedEmployment, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: absorbed.ID, OrganizationID: organization.ID, + Title: new("Engineer"), Source: store.ProvenanceUser, + }) + require.NoError(err) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-job-merge", Actor: "test", + }) + require.NoError(err) + result, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-job-exact", Actor: "test", + }) + require.NoError(err) + employments, err := st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: result.NewPerson.ID, + }) + require.NoError(err) + require.Len(employments, 1) + assert.Equal(t, absorbedEmployment.ID, employments[0].ID) + assert.Greater(t, employments[0].Revision, absorbedEmployment.Revision) +} + +func TestSplitPersonMerge_ExactReversalPreservesDeletedDeduplicationTarget(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-deleted-dedup-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-deleted-dedup-absorbed@example.com", "Absorbed") + organization := mustOrganization(t, st, "Split Deleted Dedup Employer") + for _, personID := range []int64{survivor.ID, absorbed.ID} { + _, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: personID, OrganizationID: organization.ID, + Title: new("Engineer"), Source: store.ProvenanceUser, + }) + require.NoError(err) + _, err = st.SetPersonTrackingContext(ctx, personID, true) + require.NoError(err) + } + survivor, err := st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-deleted-dedup-merge", Actor: "test", + }) + require.NoError(err) + employments, err := st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: merged.Person.ID, + }) + require.NoError(err) + require.Len(employments, 1) + require.NoError(st.DeleteEmploymentContext(ctx, employments[0].ID, employments[0].Revision)) + _, err = st.SetPersonTrackingContext(ctx, merged.Person.ID, false) + require.NoError(err) + current, err := st.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "split-deleted-dedup-split", Actor: "test", + }) + require.NoError(err) + for _, personID := range []int64{split.SourcePerson.ID, split.NewPerson.ID} { + employments, err = st.ListEmploymentsContext(ctx, store.EmploymentFilter{PersonID: personID}) + require.NoError(err) + assert.Empty(employments) + tracking, err := st.GetPersonTrackingContext(ctx, personID) + require.NoError(err) + assert.False(tracking.Tracked) + } +} + +func TestSplitPersonMerge_ExactReversalAdvancesMovedRowRevision(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-revision-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-revision-absorbed@example.com", "Absorbed") + other := mustPromotedPerson(t, st, "split-revision-other@example.com", "Other") + organization := mustOrganization(t, st, "Split Revision Organization") + original, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: absorbed.ID, OrganizationID: organization.ID, + Title: new("Engineer"), Source: store.ProvenanceUser, + }) + require.NoError(err) + originalRelationship, err := st.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: absorbed.ID, TargetPersonID: other.ID, + TypeSlug: "friend", Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + rawVCard := []byte("BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Revision\r\nEND:VCARD\r\n") + envelope := parseStoreEnvelope(t, rawVCard, "split-revision-book", "split-revision-card") + envelope.CanonicalPersonUID = absorbed.VCardUID + originalEnvelope, err := st.PutVCardResourceEnvelopeContext(ctx, store.VCardResourceEnvelopeInput{ + PersonID: absorbed.ID, Envelope: envelope, + }) + require.NoError(err) + survivorEnvelope := parseStoreEnvelope(t, + []byte("BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Survivor\r\nEND:VCARD\r\n"), + "split-revision-book", "split-revision-survivor-card") + survivorEnvelope.CanonicalPersonUID = survivor.VCardUID + _, err = st.PutVCardResourceEnvelopeContext(ctx, store.VCardResourceEnvelopeInput{ + PersonID: survivor.ID, Envelope: survivorEnvelope, + }) + require.NoError(err) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-revision-merge", Actor: "test", + }) + require.NoError(err) + employments, err := st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: merged.Person.ID, + }) + require.NoError(err) + require.Len(employments, 1) + mergedRevision := employments[0].Revision + assert.Greater(mergedRevision, original.Revision) + mergedRelationships, err := st.ListPersonRelationshipsContext( + ctx, merged.Person.ID, store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(mergedRelationships, 1) + mergedRelationshipRevision := mergedRelationships[0].Relationship.Revision + assert.Greater(mergedRelationshipRevision, originalRelationship.Revision) + mergedEnvelope, err := st.GetVCardResourceEnvelopeContext( + ctx, "split-revision-book", "split-revision-card") + require.NoError(err) + assert.Greater(mergedEnvelope.Revision, originalEnvelope.Revision) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-revision-exact", Actor: "test", + }) + require.NoError(err) + employments, err = st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: split.NewPerson.ID, + }) + require.NoError(err) + require.Len(employments, 1) + assert.Greater(employments[0].Revision, mergedRevision) + splitRelationships, err := st.ListPersonRelationshipsContext( + ctx, split.NewPerson.ID, store.PersonRelationshipListOptions{}) + require.NoError(err) + require.Len(splitRelationships, 1) + assert.Greater(splitRelationships[0].Relationship.Revision, mergedRelationshipRevision) + splitEnvelope, err := st.GetVCardResourceEnvelopeContext( + ctx, "split-revision-book", "split-revision-card") + require.NoError(err) + assert.Greater(splitEnvelope.Revision, mergedEnvelope.Revision) + assert.Equal(split.NewPerson.ID, splitEnvelope.PersonID) + assert.Equal(split.NewPerson.VCardUID, splitEnvelope.CanonicalPersonUID) + keptEnvelope, err := st.GetVCardResourceEnvelopeContext( + ctx, "split-revision-book", "split-revision-survivor-card") + require.NoError(err) + assert.Equal(split.SourcePerson.ID, keptEnvelope.PersonID) + assert.Equal(split.SourcePerson.VCardUID, keptEnvelope.CanonicalPersonUID) + err = st.DeleteEmploymentContext(ctx, employments[0].ID, original.Revision) + require.ErrorIs(err, store.ErrEmploymentRevisionConflict) +} + +func TestSplitPersonMerge_ExactReversalReleasesPersonDeletion(t *testing.T) { + require := require.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-delete-reference-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-delete-reference-absorbed@example.com", "Absorbed") + for personID, channel := range map[int64]string{ + survivor.ID: "email", absorbed.ID: "chat", + } { + _, err := st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: &channel}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + survivor, err := st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-delete-reference-merge", Actor: "test", + }) + require.NoError(err) + require.Len(merged.ReviewCandidates, 1) + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-delete-reference-exact", Actor: "test", + }) + require.NoError(err) + err = st.DeletePersonContext(ctx, split.NewPerson.ID, split.NewPerson.Revision) + require.NoError(err) +} + +func TestSplitPersonMerge_ExactReversalLeavesMissingRecordTargetInactive(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + f := newPersonMergeRecordReferenceFixture(t, "split-missing-candidate-target") + _, err := f.store.DB().ExecContext(ctx, f.store.Rebind( + `DELETE FROM persons WHERE id = ?`), f.absorbedTarget.ID) + require.NoError(err) + split, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: f.merge.Person.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: f.absorbed.ParticipantIDs, + ExpectedSourceRevision: f.merge.Person.Revision, + IdempotencyKey: "split-missing-candidate-target-exact", Actor: "test", + }) + require.NoError(err) + require.True(split.ExactReversal) + var personID, targetID int64 + var current bool + require.NoError(f.store.DB().QueryRowContext(ctx, f.store.Rebind(`SELECT person_id, + value_record_id, active_until IS NULL AND superseded_at IS NULL + FROM person_attribute_values WHERE id = ?`), f.absorbedValueID). + Scan(&personID, &targetID, ¤t)) + assert.Equal(split.NewPerson.ID, personID) + assert.Equal(f.absorbedTarget.ID, targetID) + assert.False(current, "a split must not reactivate a dangling record reference") +} + +func TestSplitPersonMerge_ExactReversalRestoresRetainedCollisionsInPlace(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-retained-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-retained-absorbed@example.com", "Absorbed") + resourceUID := "shared-resource" + propertyID := "shared-property" + sourceRef := "shared-book" + for personID, formatted := range map[int64]string{ + survivor.ID: "Survivor Name", absorbed.ID: "Absorbed Name", + } { + _, err := st.AddPersonNameContext(ctx, personID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: &formatted, + Envelope: store.ValueEnvelopeInput{ + Source: store.ProvenanceVCardImport, SourceRef: &sourceRef, + SourceResourceUID: &resourceUID, + VCard: store.VCardIdentity{Property: "FN", PropID: &propertyID}, + }, + }) + require.NoError(err) + } + absorbedName, err := st.GetPersonProfileContext(ctx, absorbed.ID) + require.NoError(err) + require.Len(absorbedName.Names, 1) + absorbedCategory, err := st.AddPersonCategoryContext(ctx, absorbed.ID, store.PersonCategoryInput{ + OriginalValue: "friends", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceVCardImport}, + }) + require.NoError(err) + _, err = st.AddPersonCategoryContext(ctx, survivor.ID, store.PersonCategoryInput{ + OriginalValue: "Friends", Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + for _, personID := range []int64{survivor.ID, absorbed.ID} { + _, err = st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: new("email")}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + absorbedValues, err := st.ListPersonAttributeValuesContext(ctx, absorbed.ID, + store.PersonAttributeQuery{DefinitionSlug: store.AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(absorbedValues, 1) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-retained-merge", Actor: "test", + }) + require.NoError(err) + result, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-retained-exact", Actor: "test", + }) + require.NoError(err) + for _, row := range []struct { + table string + id int64 + }{ + {table: "person_names", id: absorbedName.Names[0].Envelope.ID}, + {table: "person_categories", id: absorbedCategory.Envelope.ID}, + {table: "person_attribute_values", id: absorbedValues[0].ID}, + } { + var personID int64 + var current bool + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT person_id, + active_until IS NULL AND superseded_at IS NULL FROM `+row.table+` WHERE id = ?`), + row.id).Scan(&personID, ¤t)) + assert.Equal(result.NewPerson.ID, personID, row.table) + assert.True(current, row.table) + } +} + +func TestSplitPersonMerge_ExactReversalFinalizesPendingCandidates(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + f := storetest.New(t) + survivor := mustPromotedPerson(t, f.Store, "split-candidate-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, f.Store, "split-candidate-absorbed@example.com", "Absorbed") + for personID, channel := range map[int64]string{survivor.ID: "email", absorbed.ID: "chat"} { + _, err := f.Store.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: &channel}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + survivor, err := f.Store.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = f.Store.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-candidate-merge", Actor: "test", + }) + require.NoError(err) + require.Len(merged.ReviewCandidates, 1) + _, err = f.Store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-candidate-exact", Actor: "split-reviewer", + }) + require.NoError(err) + var state, reviewedBy string + var reviewedAt sql.NullTime + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT state, reviewed_by, reviewed_at + FROM person_merge_review_candidates WHERE id = ?`), merged.ReviewCandidates[0].ID). + Scan(&state, &reviewedBy, &reviewedAt)) + assert.Equal("rejected", state) + assert.Equal("split-reviewer", reviewedBy) + assert.True(reviewedAt.Valid) +} + +func TestSplitPersonMerge_ExactReversalRejectsAcceptedCandidates(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + f := storetest.New(t) + survivor := mustPromotedPerson(t, f.Store, "split-reviewed-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, f.Store, "split-reviewed-absorbed@example.com", "Absorbed") + for personID, channel := range map[int64]string{survivor.ID: "email", absorbed.ID: "chat"} { + _, err := f.Store.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: &channel}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + survivor, err := f.Store.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = f.Store.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-reviewed-merge", Actor: "test", + }) + require.NoError(err) + require.Len(merged.ReviewCandidates, 1) + accepted, err := f.Store.DecidePersonMergeCandidateContext(ctx, + store.PersonMergeCandidateDecisionRequest{ + CandidateID: merged.ReviewCandidates[0].ID, PersonID: merged.Person.ID, + ExpectedPersonRevision: merged.Person.Revision, + Decision: store.PersonMergeCandidateAccept, Actor: "reviewer", + }) + require.NoError(err) + assert.Equal("accepted", accepted.State) + current, err := f.Store.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + var peopleBefore int + require.NoError(f.Store.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM persons`).Scan(&peopleBefore)) + + _, err = f.Store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "split-reviewed-exact", Actor: "test", + }) + require.ErrorIs(err, store.ErrPersonSplitReviewed) + var peopleAfter, splitCount int + require.NoError(f.Store.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM persons`).Scan(&peopleAfter)) + require.NoError(f.Store.DB().QueryRowContext(ctx, + f.Store.Rebind(`SELECT COUNT(*) FROM person_splits WHERE merge_id = ?`), merged.Merge.ID).Scan(&splitCount)) + assert.Equal(peopleBefore, peopleAfter) + assert.Zero(splitCount) +} + +func TestSplitPersonMerge_ExactReversalRejectsAcceptedAbsorbedCandidateFromEarlierMerge( + t *testing.T, +) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + f := storetest.New(t) + first := mustPromotedPerson(t, f.Store, "split-reviewed-chain-first@example.com", "First") + second := mustPromotedPerson(t, f.Store, "split-reviewed-chain-second@example.com", "Second") + third := mustPromotedPerson(t, f.Store, "split-reviewed-chain-third@example.com", "Third") + for personID, channel := range map[int64]string{first.ID: "email", second.ID: "chat"} { + _, err := f.Store.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: &channel}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + first, err := f.Store.GetPersonContext(ctx, first.ID) + require.NoError(err) + second, err = f.Store.GetPersonContext(ctx, second.ID) + require.NoError(err) + firstMerge, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: first.ID, AbsorbedID: second.ID, + ExpectedSurvivorRevision: first.Revision, + ExpectedAbsorbedRevision: second.Revision, + IdempotencyKey: "split-reviewed-chain-first-merge", Actor: "test", + }) + require.NoError(err) + require.Len(firstMerge.ReviewCandidates, 1) + third, err = f.Store.GetPersonContext(ctx, third.ID) + require.NoError(err) + secondMerge, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: third.ID, AbsorbedID: firstMerge.Person.ID, + ExpectedSurvivorRevision: third.Revision, + ExpectedAbsorbedRevision: firstMerge.Person.Revision, + IdempotencyKey: "split-reviewed-chain-second-merge", Actor: "test", + }) + require.NoError(err) + accepted, err := f.Store.DecidePersonMergeCandidateContext(ctx, + store.PersonMergeCandidateDecisionRequest{ + CandidateID: firstMerge.ReviewCandidates[0].ID, PersonID: secondMerge.Person.ID, + ExpectedPersonRevision: secondMerge.Person.Revision, + Decision: store.PersonMergeCandidateAccept, Actor: "reviewer", + }) + require.NoError(err) + assert.Equal("accepted", accepted.State) + current, err := f.Store.GetPersonContext(ctx, secondMerge.Person.ID) + require.NoError(err) + var peopleBefore int + require.NoError(f.Store.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM persons`).Scan(&peopleBefore)) + + _, err = f.Store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: secondMerge.Merge.ID, + ParticipantIDs: firstMerge.Person.ParticipantIDs, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "split-reviewed-chain-exact", Actor: "test", + }) + require.ErrorIs(err, store.ErrPersonSplitReviewed) + var peopleAfter, splitCount int + require.NoError(f.Store.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM persons`).Scan(&peopleAfter)) + require.NoError(f.Store.DB().QueryRowContext(ctx, + f.Store.Rebind(`SELECT COUNT(*) FROM person_splits WHERE merge_id = ?`), + secondMerge.Merge.ID).Scan(&splitCount)) + assert.Equal(peopleBefore, peopleAfter) + assert.Zero(splitCount) +} + +func TestSplitPersonMerge_ExactReversalAllowsCandidateAcceptedBeforeMerge(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + f := storetest.New(t) + first := mustPromotedPerson(t, f.Store, "split-preaccepted-first@example.com", "First") + second := mustPromotedPerson(t, f.Store, "split-preaccepted-second@example.com", "Second") + third := mustPromotedPerson(t, f.Store, "split-preaccepted-third@example.com", "Third") + for personID, channel := range map[int64]string{first.ID: "email", second.ID: "chat"} { + _, err := f.Store.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: &channel}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + } + first, err := f.Store.GetPersonContext(ctx, first.ID) + require.NoError(err) + second, err = f.Store.GetPersonContext(ctx, second.ID) + require.NoError(err) + firstMerge, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: first.ID, AbsorbedID: second.ID, + ExpectedSurvivorRevision: first.Revision, + ExpectedAbsorbedRevision: second.Revision, + IdempotencyKey: "split-preaccepted-first-merge", Actor: "test", + }) + require.NoError(err) + require.Len(firstMerge.ReviewCandidates, 1) + accepted, err := f.Store.DecidePersonMergeCandidateContext(ctx, + store.PersonMergeCandidateDecisionRequest{ + CandidateID: firstMerge.ReviewCandidates[0].ID, PersonID: firstMerge.Person.ID, + ExpectedPersonRevision: firstMerge.Person.Revision, + Decision: store.PersonMergeCandidateAccept, Actor: "reviewer", + }) + require.NoError(err) + assert.Equal("accepted", accepted.State) + currentFirst, err := f.Store.GetPersonContext(ctx, firstMerge.Person.ID) + require.NoError(err) + third, err = f.Store.GetPersonContext(ctx, third.ID) + require.NoError(err) + secondMerge, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: third.ID, AbsorbedID: currentFirst.ID, + ExpectedSurvivorRevision: third.Revision, + ExpectedAbsorbedRevision: currentFirst.Revision, + IdempotencyKey: "split-preaccepted-second-merge", Actor: "test", + }) + require.NoError(err) + split, err := f.Store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: secondMerge.Person.ID, MergeID: secondMerge.Merge.ID, + ParticipantIDs: currentFirst.ParticipantIDs, + ExpectedSourceRevision: secondMerge.Person.Revision, + IdempotencyKey: "split-preaccepted-second-split", Actor: "test", + }) + require.NoError(err) + assert.True(split.ExactReversal) + assert.Empty(split.UnrestoredRows) +} + +func TestSplitPersonMerge_IdempotencyReplaysCommittedResultAfterLaterChanges(t *testing.T) { + require := require.New(t) + ctx := context.Background() + f := newPersonSplitFixture(t) + request := store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: f.absorbedParticipants, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-immutable-replay", Actor: "test", + } + committed, err := f.store.SplitPersonMergeContext(ctx, request) + require.NoError(err) + _, err = f.store.UpdatePersonDisplayNameContext( + ctx, committed.SourcePerson.ID, committed.SourcePerson.Revision, new("Changed source")) + require.NoError(err) + _, err = f.store.UpdatePersonDisplayNameContext( + ctx, committed.NewPerson.ID, committed.NewPerson.Revision, new("Changed new person")) + require.NoError(err) + replayed, err := f.store.SplitPersonMergeContext(ctx, request) + require.NoError(err) + assertJSONEquivalent(t, committed, replayed) + currentSource, err := f.store.GetPersonContext(ctx, committed.SourcePerson.ID) + require.NoError(err) + currentNew, err := f.store.GetPersonContext(ctx, committed.NewPerson.ID) + require.NoError(err) + _, err = f.store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: currentSource.ID, AbsorbedID: currentNew.ID, + ExpectedSurvivorRevision: currentSource.Revision, + ExpectedAbsorbedRevision: currentNew.Revision, + IdempotencyKey: "merge-after-split-replay", Actor: "test", + }) + require.NoError(err) + replayed, err = f.store.SplitPersonMergeContext(ctx, request) + require.NoError(err) + assertJSONEquivalent(t, committed, replayed) +} + +func TestSplitPersonMerge_ExactReversalRestoresTracking(t *testing.T) { + for _, test := range []struct { + name string + trackSurvivor bool + wantSourceTrack bool + }{ + {name: "absorbed-only", wantSourceTrack: false}, + {name: "both-profiles", trackSurvivor: true, wantSourceTrack: true}, + } { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := t.Context() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, + "tracking-survivor-"+test.name+"@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, + "tracking-absorbed-"+test.name+"@example.com", "Absorbed") + if test.trackSurvivor { + _, err := st.SetPersonTrackingContext(ctx, survivor.ID, true) + require.NoError(err) + } + _, err := st.SetPersonTrackingContext(ctx, absorbed.ID, true) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "tracking-merge-" + test.name, Actor: "test", + }) + require.NoError(err) + mergedTracking, err := st.GetPersonTrackingContext(ctx, merged.Person.ID) + require.NoError(err) + assert.True(mergedTracking.Tracked) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "tracking-split-" + test.name, Actor: "test", + }) + require.NoError(err) + require.True(split.ExactReversal) + sourceTracking, err := st.GetPersonTrackingContext(ctx, split.SourcePerson.ID) + require.NoError(err) + newTracking, err := st.GetPersonTrackingContext(ctx, split.NewPerson.ID) + require.NoError(err) + assert.Equal(test.wantSourceTrack, sourceTracking.Tracked) + assert.True(newTracking.Tracked) + }) + } +} + +func TestSplitPersonMerge_ExactReversalReconcilesDeduplicatedTracking(t *testing.T) { + t.Run("preserves supported changes", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := t.Context() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, + "tracking-change-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, + "tracking-change-absorbed@example.com", "Absorbed") + _, err := st.SetPersonTrackingContext(ctx, survivor.ID, true) + require.NoError(err) + _, err = st.SetPersonTrackingContext(ctx, absorbed.ID, true) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "tracking-change-merge", Actor: "test", + }) + require.NoError(err) + changedAt := time.Date(2031, 2, 3, 4, 5, 6, 0, time.UTC) + _, err = st.DB().ExecContext(ctx, st.Rebind(`UPDATE person_tracking + SET tracked_at = ? WHERE person_id = ?`), changedAt, merged.Person.ID) + require.NoError(err) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "tracking-change-split", Actor: "test", + }) + require.NoError(err) + newTracking, err := st.GetPersonTrackingContext(ctx, split.NewPerson.ID) + require.NoError(err) + require.NotNil(newTracking.TrackedAt) + assert.True(changedAt.Equal(*newTracking.TrackedAt)) + }) + + t.Run("does not restore after reassignment", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := t.Context() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, + "tracking-reassign-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, + "tracking-reassign-absorbed@example.com", "Absorbed") + reassigned := mustPromotedPerson(t, st, + "tracking-reassign-target@example.com", "Target") + _, err := st.SetPersonTrackingContext(ctx, survivor.ID, true) + require.NoError(err) + _, err = st.SetPersonTrackingContext(ctx, absorbed.ID, true) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "tracking-reassign-merge", Actor: "test", + }) + require.NoError(err) + _, err = st.DB().ExecContext(ctx, st.Rebind(`UPDATE person_tracking + SET person_id = ? WHERE person_id = ?`), reassigned.ID, merged.Person.ID) + require.NoError(err) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "tracking-reassign-split", Actor: "test", + }) + require.NoError(err) + sourceTracking, err := st.GetPersonTrackingContext(ctx, split.SourcePerson.ID) + require.NoError(err) + newTracking, err := st.GetPersonTrackingContext(ctx, split.NewPerson.ID) + require.NoError(err) + reassignedTracking, err := st.GetPersonTrackingContext(ctx, reassigned.ID) + require.NoError(err) + assert.False(sourceTracking.Tracked) + assert.False(newTracking.Tracked) + assert.True(reassignedTracking.Tracked) + }) +} + +func TestSplitPersonMerge_ExactReversalPreservesRecreatedTracking(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, + "tracking-recreated-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, + "tracking-recreated-absorbed@example.com", "Absorbed") + _, err := st.SetPersonTrackingContext(ctx, absorbed.ID, true) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "tracking-recreated-merge", Actor: "test", + }) + require.NoError(err) + _, err = st.SetPersonTrackingContext(ctx, merged.Person.ID, false) + require.NoError(err) + _, err = st.SetPersonTrackingContext(ctx, merged.Person.ID, true) + require.NoError(err) + replacementTime := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + _, err = st.DB().ExecContext(ctx, st.Rebind(`UPDATE person_tracking + SET tracked_at = ? WHERE person_id = ?`), replacementTime, merged.Person.ID) + require.NoError(err) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "tracking-recreated-split", Actor: "test", + }) + require.NoError(err) + sourceTracking, err := st.GetPersonTrackingContext(ctx, split.SourcePerson.ID) + require.NoError(err) + newTracking, err := st.GetPersonTrackingContext(ctx, split.NewPerson.ID) + require.NoError(err) + assert.True(sourceTracking.Tracked) + require.NotNil(sourceTracking.TrackedAt) + assert.True(replacementTime.Equal(*sourceTracking.TrackedAt)) + assert.True(newTracking.Tracked) + require.NotNil(newTracking.TrackedAt) + assert.False(replacementTime.Equal(*newTracking.TrackedAt)) +} + +func TestSplitPersonMerge_ExactReversalThreeWayRestoresMergeOwnedFields(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-fields-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-fields-absorbed@example.com", "Absorbed") + survivorOrg := mustOrganization(t, st, "Split Survivor Primary") + absorbedOrg := mustOrganization(t, st, "Split Absorbed Primary") + _, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: survivor.ID, OrganizationID: survivorOrg.ID, Title: new("Engineer"), + IsPrimary: new(true), Source: store.ProvenanceUser, + }) + require.NoError(err) + absorbedEmployment, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: absorbed.ID, OrganizationID: absorbedOrg.ID, Title: new("Advisor"), + IsPrimary: new(true), Source: store.ProvenanceUser, + }) + require.NoError(err) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-fields-merge", Actor: "test", + }) + require.NoError(err) + + // The merge demotes the absorbed primary. A later title edit belongs to + // the user and must survive while the merge-owned demotion is reversed. + _, err = st.DB().ExecContext(ctx, st.Rebind(`UPDATE employments SET title = ? WHERE id = ?`), + "Curated Advisor", absorbedEmployment.ID) + require.NoError(err) + result, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-fields-exact", Actor: "test", + }) + require.NoError(err) + employments, err := st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: result.NewPerson.ID, + }) + require.NoError(err) + require.Len(employments, 1) + assert.Equal(absorbedEmployment.ID, employments[0].ID) + assert.True(employments[0].IsPrimary, "split reverses the merge-owned demotion") + require.NotNil(employments[0].Title) + assert.Equal("Curated Advisor", *employments[0].Title, + "split preserves a field edited after the merge") +} + +func TestSplitPersonMerge_ExactReversalPreservesPostMergePersonReassignment(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-reassign-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-reassign-absorbed@example.com", "Absorbed") + third := mustPromotedPerson(t, st, "split-reassign-third@example.com", "Third") + organization := mustOrganization(t, st, "Split Reassignment Organization") + absorbedEmployment, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: absorbed.ID, OrganizationID: organization.ID, + Title: new("Engineer"), Source: store.ProvenanceUser, + }) + require.NoError(err) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-reassign-merge", Actor: "test", + }) + require.NoError(err) + mergedEmployment, err := st.GetEmploymentContext(ctx, absorbedEmployment.ID) + require.NoError(err) + reassigned, err := st.UpdateEmploymentContext(ctx, mergedEmployment.ID, + mergedEmployment.Revision, store.EmploymentInput{ + PersonID: third.ID, OrganizationID: organization.ID, + Title: new("Engineer"), Source: store.ProvenanceUser, + }) + require.NoError(err) + assert.Equal(third.ID, reassigned.PersonID) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-reassign-exact", Actor: "test", + }) + require.NoError(err) + assert.True(split.ExactReversal) + assert.Empty(split.UnrestoredRows) + reassigned, err = st.GetEmploymentContext(ctx, absorbedEmployment.ID) + require.NoError(err) + assert.Equal(third.ID, reassigned.PersonID) + for _, personID := range []int64{split.SourcePerson.ID, split.NewPerson.ID} { + employments, listErr := st.ListEmploymentsContext(ctx, store.EmploymentFilter{ + PersonID: personID, + }) + require.NoError(listErr) + assert.Empty(employments) + } +} + +func TestSplitPersonMerge_ExactReversalSkipsDeletedRelationshipType(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, + "split-dependency-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, + "split-dependency-absorbed@example.com", "Absorbed") + reviewOwner := mustPromotedPerson(t, st, + "split-dependency-review@example.com", "Review Owner") + relationshipType, err := st.CreateRelationshipTypeContext(ctx, + store.RelationshipTypeInput{ + Slug: "former-colleague", ForwardLabel: "former colleague", + ReverseLabel: "former colleague", IsSymmetric: true, + }) + require.NoError(err) + selfEdge, err := st.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: survivor.ID, TargetPersonID: absorbed.ID, + TypeSlug: relationshipType.Slug, Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + var reviewID int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`INSERT INTO person_relationship_reviews ( + person_id, raw_related_value, raw_related_type, value_kind, + accepted_relationship_id, status, source, created_by, reviewed_by, reviewed_at + ) VALUES (?, ?, ?, 'uri', ?, 'accepted', 'user', 'test', 'test', CURRENT_TIMESTAMP) + RETURNING id`), reviewOwner.ID, absorbed.VCardUID, relationshipType.Slug, + selfEdge.ID).Scan(&reviewID)) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-dependency-merge", Actor: "test", + }) + require.NoError(err) + var acceptedAfterMerge sql.NullInt64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT accepted_relationship_id + FROM person_relationship_reviews WHERE id = ?`), reviewID).Scan(&acceptedAfterMerge)) + assert.False(acceptedAfterMerge.Valid) + require.NoError(st.DeleteRelationshipTypeContext( + ctx, relationshipType.ID, relationshipType.Revision)) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-dependency-exact", Actor: "test", + }) + require.NoError(err) + assert.False(split.ExactReversal) + assert.NotEmpty(split.UnrestoredRows) + for _, personID := range []int64{split.SourcePerson.ID, split.NewPerson.ID} { + relationships, listErr := st.ListPersonRelationshipsContext( + ctx, personID, store.PersonRelationshipListOptions{}) + require.NoError(listErr) + assert.Empty(relationships) + } + var acceptedAfterSplit sql.NullInt64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT accepted_relationship_id + FROM person_relationship_reviews WHERE id = ?`), reviewID).Scan(&acceptedAfterSplit)) + assert.False(acceptedAfterSplit.Valid) +} + +func TestSplitPersonMerge_ExactReversalNullsMissingDependencyOnRecreatedReview(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, + "split-recreated-review-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, + "split-recreated-review-absorbed@example.com", "Absorbed") + other := mustPromotedPerson(t, st, + "split-recreated-review-other@example.com", "Other") + matched := mustPromotedPerson(t, st, + "split-recreated-review-matched@example.com", "Matched") + relationshipType, err := st.CreateRelationshipTypeContext(ctx, + store.RelationshipTypeInput{ + Slug: "former-teammate", ForwardLabel: "former teammate", + ReverseLabel: "former teammate", IsSymmetric: true, + }) + require.NoError(err) + survivorEdge, err := st.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: survivor.ID, TargetPersonID: other.ID, + TypeSlug: relationshipType.Slug, Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + absorbedEdge, err := st.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: absorbed.ID, TargetPersonID: other.ID, + TypeSlug: relationshipType.Slug, Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + insertReview := func(personID, relationshipID int64) int64 { + t.Helper() + var reviewID int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`INSERT INTO person_relationship_reviews ( + person_id, raw_related_value, raw_related_type, value_kind, + accepted_relationship_id, status, source, source_ref, created_by, + reviewed_by, reviewed_at + ) VALUES (?, 'shared-review', ?, 'text', ?, 'accepted', 'user', + 'split-recreated-review', 'test', 'test', CURRENT_TIMESTAMP) + RETURNING id`), personID, relationshipType.Slug, relationshipID).Scan(&reviewID)) + return reviewID + } + _ = insertReview(survivor.ID, survivorEdge.ID) + absorbedReviewID := insertReview(absorbed.ID, absorbedEdge.ID) + _, err = st.DB().ExecContext(ctx, st.Rebind(`UPDATE person_relationship_reviews + SET matched_person_id = ? WHERE id = ?`), matched.ID, absorbedReviewID) + require.NoError(err) + + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-recreated-review-merge", Actor: "test", + }) + require.NoError(err) + var reviewCount int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind( + `SELECT COUNT(*) FROM person_relationship_reviews WHERE id = ?`), + absorbedReviewID).Scan(&reviewCount)) + assert.Zero(reviewCount) + matched, err = st.GetPersonContext(ctx, matched.ID) + require.NoError(err) + require.NoError(st.DeletePersonContext(ctx, matched.ID, matched.Revision)) + require.NoError(st.DeletePersonRelationshipContext( + ctx, survivorEdge.ID, survivorEdge.Revision)) + require.NoError(st.DeleteRelationshipTypeContext( + ctx, relationshipType.ID, relationshipType.Revision)) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-recreated-review-exact", Actor: "test", + }) + require.NoError(err) + assert.False(split.ExactReversal) + assert.NotEmpty(split.UnrestoredRows) + var acceptedRelationshipID, matchedPersonID sql.NullInt64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT accepted_relationship_id, + matched_person_id + FROM person_relationship_reviews WHERE id = ?`), + absorbedReviewID).Scan(&acceptedRelationshipID, &matchedPersonID)) + assert.False(acceptedRelationshipID.Valid) + assert.False(matchedPersonID.Valid) +} + +func TestSplitPersonMerge_ExactReversalRestoresRelationshipReviewDependency(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-review-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-review-absorbed@example.com", "Absorbed") + other := mustPromotedPerson(t, st, "split-review-other@example.com", "Other") + reviewOwner := mustPromotedPerson(t, st, "split-review-owner@example.com", "Review Owner") + pendingOwner := mustPromotedPerson(t, st, "split-review-pending@example.com", "Pending Owner") + survivorEdge, err := st.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: survivor.ID, TargetPersonID: other.ID, TypeSlug: "friend", + Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + absorbedEdge, err := st.AddPersonRelationshipContext(ctx, store.PersonRelationshipInput{ + SourcePersonID: absorbed.ID, TargetPersonID: other.ID, TypeSlug: "friend", + Source: store.ProvenanceUser, Actor: "test", + }) + require.NoError(err) + resolution, err := st.ResolveRelatedValueContext(ctx, store.RelatedImport{ + PersonID: reviewOwner.ID, RawValue: survivor.VCardUID, RawType: "friend", + ValueKind: store.RelatedValueKindURI, Source: store.ProvenanceVCardImport, + Actor: "test", SourceRef: new("dependency-review"), + }) + require.NoError(err) + require.NotNil(resolution.Review) + _, err = st.DB().ExecContext(ctx, st.Rebind(`UPDATE person_relationship_reviews + SET accepted_relationship_id = ?, matched_person_id = NULL WHERE id = ?`), + absorbedEdge.ID, resolution.Review.ID) + require.NoError(err) + var pendingReviewID int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`INSERT INTO person_relationship_reviews ( + person_id, raw_related_value, raw_related_type, value_kind, matched_person_id, + status, source, source_ref, created_by + ) VALUES (?, ?, 'friend', 'uri', ?, 'pending', 'vcard_import', ?, 'test') RETURNING id`), + pendingOwner.ID, absorbed.VCardUID, absorbed.ID, "split-review-pending").Scan(&pendingReviewID)) + var reviewProjectionBefore, pendingProjectionBefore int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT vcard_projection_revision + FROM persons WHERE id = ?`), reviewOwner.ID).Scan(&reviewProjectionBefore)) + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT vcard_projection_revision + FROM persons WHERE id = ?`), pendingOwner.ID).Scan(&pendingProjectionBefore)) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-review-merge", Actor: "test", + }) + require.NoError(err) + var acceptedAfterMerge int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT accepted_relationship_id + FROM person_relationship_reviews WHERE id = ?`), resolution.Review.ID).Scan(&acceptedAfterMerge)) + assert.Equal(survivorEdge.ID, acceptedAfterMerge) + var reviewProjectionAfterMerge, pendingProjectionAfterMerge int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT vcard_projection_revision + FROM persons WHERE id = ?`), reviewOwner.ID).Scan(&reviewProjectionAfterMerge)) + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT vcard_projection_revision + FROM persons WHERE id = ?`), pendingOwner.ID).Scan(&pendingProjectionAfterMerge)) + assert.Equal(reviewProjectionBefore+1, reviewProjectionAfterMerge) + assert.Equal(pendingProjectionBefore+1, pendingProjectionAfterMerge) + + split, err := st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-review-exact", Actor: "test", + }) + require.NoError(err) + var acceptedAfterSplit int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT accepted_relationship_id + FROM person_relationship_reviews WHERE id = ?`), resolution.Review.ID).Scan(&acceptedAfterSplit)) + assert.Equal(absorbedEdge.ID, acceptedAfterSplit) + var pendingMatchedPersonID int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT matched_person_id + FROM person_relationship_reviews WHERE id = ?`), pendingReviewID).Scan(&pendingMatchedPersonID)) + assert.Equal(split.NewPerson.ID, pendingMatchedPersonID) + var reviewProjectionAfterSplit, pendingProjectionAfterSplit int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT vcard_projection_revision + FROM persons WHERE id = ?`), reviewOwner.ID).Scan(&reviewProjectionAfterSplit)) + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT vcard_projection_revision + FROM persons WHERE id = ?`), pendingOwner.ID).Scan(&pendingProjectionAfterSplit)) + assert.Equal(reviewProjectionAfterMerge+1, reviewProjectionAfterSplit) + assert.Equal(pendingProjectionAfterMerge+1, pendingProjectionAfterSplit) +} + +func TestSplitPersonMerge_ExactReversalRestoresIdentityEvidenceDependency(t *testing.T) { + require := require.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + survivor := mustPromotedPerson(t, st, "split-evidence-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "split-evidence-absorbed@example.com", "Absorbed") + other := mustPromotedPerson(t, st, "split-evidence-other@example.com", "Other") + source, err := st.GetOrCreateSource("gmail", "split-evidence") + require.NoError(err) + input := func(personID int64) store.IdentityMatchCandidateInput { + return store.IdentityMatchCandidateInput{ + LeftKind: store.IdentityMatchPerson, LeftID: personID, + RightKind: store.IdentityMatchPerson, RightID: other.ID, + Basis: store.IdentityMatchDisplayName, NormalizedValue: new("same person"), + State: store.IdentityMatchStateCandidate, Source: store.ProvenanceUser, + SourceID: &source.ID, + } + } + absorbedCandidate, _, err := st.UpsertIdentityMatchCandidateContext(ctx, input(absorbed.ID)) + require.NoError(err) + survivorCandidate, _, err := st.UpsertIdentityMatchCandidateContext(ctx, input(survivor.ID)) + require.NoError(err) + evidence, err := st.AddIdentityMatchEvidenceContext(ctx, absorbedCandidate.ID, + store.IdentityMatchEvidenceInput{ + EvidenceKind: "shared_name", Source: store.ProvenanceUser, SourceID: &source.ID, + }) + require.NoError(err) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-evidence-merge", Actor: "test", + }) + require.NoError(err) + var candidateAfterMerge int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT candidate_id + FROM identity_match_evidence WHERE id = ?`), evidence.ID).Scan(&candidateAfterMerge)) + assert.Equal(t, survivorCandidate.ID, candidateAfterMerge) + + _, err = st.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-evidence-exact", Actor: "test", + }) + require.NoError(err) + var candidateAfterSplit int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(`SELECT candidate_id + FROM identity_match_evidence WHERE id = ?`), evidence.ID).Scan(&candidateAfterSplit)) + assert.Equal(t, absorbedCandidate.ID, candidateAfterSplit) +} + +func TestSplitPersonMerge_Partial(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonSplitFixture(t) + ctx := context.Background() + postMergeName, err := f.store.AddPersonNameContext(ctx, f.survivor.ID, store.PersonNameInput{ + NameKind: store.PersonNameFormatted, Formatted: new("Post Merge"), + Envelope: store.ValueEnvelopeInput{Source: store.ProvenanceUser}, + }) + require.NoError(err) + f.survivor, err = f.store.GetPersonContext(ctx, f.survivor.ID) + require.NoError(err) + result, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: []int64{f.absorbedParticipants[0]}, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-partial", Actor: "test", + }) + require.NoError(err) + assert.False(result.ExactReversal) + assert.Equal("retired_uid_alias_unchanged", result.UIDAliasDisposition) + assert.Equal([]int64{f.absorbedParticipants[0]}, result.NewPerson.ParticipantIDs) + assert.ElementsMatch([]int64{f.survivorParticipant, f.absorbedParticipants[1]}, + result.SourcePerson.ParticipantIDs) + assert.NotEmpty(result.AmbiguousRows) + sourceProfile, err := f.store.GetPersonProfileContext(ctx, result.SourcePerson.ID) + require.NoError(err) + var sourceNameIDs []int64 + for _, name := range sourceProfile.Names { + sourceNameIDs = append(sourceNameIDs, name.Envelope.ID) + } + assert.Contains(sourceNameIDs, f.absorbedNameID) + assert.Contains(sourceNameIDs, postMergeName.Envelope.ID) + newProfile, err := f.store.GetPersonProfileContext(ctx, result.NewPerson.ID) + require.NoError(err) + assert.Empty(newProfile.Names) + alias, err := f.store.ResolveRetiredPersonUIDContext(ctx, f.absorbedUID) + require.NoError(err) + require.NotNil(alias.SurvivingPersonID) + assert.Equal(result.SourcePerson.ID, *alias.SurvivingPersonID) +} + +func TestSplitPersonMerge_PartialRejectsAcceptedCandidateAcrossBoundary(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonSplitFixture(t) + ctx := context.Background() + selected := f.absorbedParticipants[0] + retained := f.absorbedParticipants[1] + _, err := f.store.UnlinkParticipants(selected, retained) + require.NoError(err) + candidate := upsertPairCandidate( + t, f.store, selected, retained, store.IdentityMatchStableProviderID) + _, _, err = f.store.AcceptIdentityMatchCandidateContext( + ctx, candidate.ID, "system", nil) + require.NoError(err) + lo, hi := normalizeEdgeForTest(selected, retained) + var linkOwner int64 + require.NoError(f.store.DB().QueryRowContext(ctx, f.store.Rebind(` + SELECT identity_match_candidate_id FROM participant_links + WHERE participant_a = ? AND participant_b = ?`), lo, hi).Scan(&linkOwner)) + require.Equal(candidate.ID, linkOwner, "accepted candidate must own the crossing link") + + _, err = f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: []int64{selected}, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-partial-accepted-candidate", Actor: "test", + }) + require.NoError(err) + reloaded, err := f.store.GetIdentityMatchCandidateContext(ctx, candidate.ID) + require.NoError(err) + assert.Equal(store.IdentityMatchStateRejected, reloaded.State) + require.NotNil(reloaded.DecidedBy) + assert.Equal("user", *reloaded.DecidedBy) + assert.False(linkedPair(t, f.store, selected, retained)) +} + +func TestSplitPersonMerge_PartialReplayExcludesSurvivorJournalRows(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonSplitFixture(t) + ctx := context.Background() + _, err := f.store.DB().ExecContext(ctx, f.store.Rebind(`UPDATE person_merge_rows + SET origin_side = 'survivor' + WHERE merge_id = ? AND table_name = 'person_names' AND original_row_id = ?`), + f.merge.Merge.ID, f.absorbedNameID) + require.NoError(err) + request := store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: []int64{f.absorbedParticipants[0]}, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-partial-replay", Actor: "test", + } + first, err := f.store.SplitPersonMergeContext(ctx, request) + require.NoError(err) + replayed, err := f.store.SplitPersonMergeContext(ctx, request) + require.NoError(err) + assert.Equal(first.AmbiguousRows, replayed.AmbiguousRows) + for _, row := range replayed.AmbiguousRows { + if row.TableName == "person_names" && row.OriginalRowID != nil { + assert.NotEqual(f.absorbedNameID, *row.OriginalRowID) + } + } +} + +func TestSplitPersonMerge_CutsIdentityLinks(t *testing.T) { + f := newPersonSplitFixture(t) + ctx := context.Background() + result, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: []int64{f.absorbedParticipants[0]}, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-links", Actor: "test", + }) + require.NoError(t, err) + selectedCluster, err := f.store.ClusterMembers(result.NewPerson.ParticipantIDs[0]) + require.NoError(t, err) + assert.Equal(t, []int64{result.NewPerson.ParticipantIDs[0]}, selectedCluster) +} + +func TestSplitPersonMerge_SequentialPartialSplitsReleasePersonDeletion(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonSplitFixture(t) + ctx := context.Background() + first, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: []int64{f.absorbedParticipants[0]}, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-first-partial", Actor: "test", + }) + require.NoError(err) + second, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: first.SourcePerson.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: []int64{f.absorbedParticipants[1]}, + ExpectedSourceRevision: first.SourcePerson.Revision, + IdempotencyKey: "split-second-partial", Actor: "test", + }) + require.NoError(err) + assert.False(second.ExactReversal) + assert.Equal("retired_uid_alias_unchanged", second.UIDAliasDisposition) + require.NoError(f.store.DeletePersonContext( + ctx, second.SourcePerson.ID, second.SourcePerson.Revision, + )) +} + +func TestSplitPersonMerge_Validation(t *testing.T) { + require := require.New(t) + f := newPersonSplitFixture(t) + ctx := context.Background() + base := store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: []int64{f.absorbedParticipants[0]}, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-validation", Actor: "test", + } + stale := base + stale.ExpectedSourceRevision++ + _, err := f.store.SplitPersonMergeContext(ctx, stale) + require.ErrorIs(err, store.ErrPersonSplitRevision) + unknown := base + unknown.IdempotencyKey = "split-unknown" + unknown.ParticipantIDs = []int64{f.survivorParticipant} + _, err = f.store.SplitPersonMergeContext(ctx, unknown) + require.ErrorIs(err, store.ErrPersonSplitParticipants) + + first, err := f.store.SplitPersonMergeContext(ctx, base) + require.NoError(err) + replayed, err := f.store.SplitPersonMergeContext(ctx, base) + require.NoError(err) + assertJSONEquivalent(t, first, replayed) + changedActor := base + changedActor.Actor = "different-actor" + _, err = f.store.SplitPersonMergeContext(ctx, changedActor) + require.ErrorIs(err, store.ErrPersonSplitIdempotency) + changed := base + changed.ParticipantIDs = []int64{f.absorbedParticipants[1]} + _, err = f.store.SplitPersonMergeContext(ctx, changed) + require.ErrorIs(err, store.ErrPersonSplitIdempotency) + + current, err := f.store.GetPersonContext(ctx, f.survivor.ID) + require.NoError(err) + alreadySplit := base + alreadySplit.ExpectedSourceRevision = current.Revision + alreadySplit.IdempotencyKey = "split-already-split" + _, err = f.store.SplitPersonMergeContext(ctx, alreadySplit) + require.ErrorIs(err, store.ErrPersonMergeAlreadySplit) + + mixed := alreadySplit + mixed.IdempotencyKey = "split-mixed" + mixed.ParticipantIDs = []int64{f.survivorParticipant, f.absorbedParticipants[1]} + _, err = f.store.SplitPersonMergeContext(ctx, mixed) + require.ErrorIs(err, store.ErrPersonSplitParticipants) +} + +func TestSplitPersonMerge_RecomputesActivityAndContactState(t *testing.T) { + require := require.New(t) + f := storetest.New(t) + ctx := context.Background() + survivorParticipant := f.EnsureParticipant( + "split-activity-survivor@example.com", "Survivor", "example.com") + absorbedParticipant := f.EnsureParticipant( + "split-activity-absorbed@example.com", "Absorbed", "example.com") + ownerParticipant := f.EnsureParticipant( + "split-activity-owner@example.com", "Owner", "example.com") + require.NoError(f.Store.AddAccountIdentity( + f.Source.ID, "split-activity-owner@example.com", "test")) + survivor, _, err := f.Store.CreatePersonFromParticipant(survivorParticipant) + require.NoError(err) + absorbed, _, err := f.Store.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + + messageIDs := make([]int64, 0, 2) + for label, senderID := range map[string]int64{ + "survivor": survivorParticipant, + "absorbed": absorbedParticipant, + } { + message := f.NewMessage(). + WithSourceMessageID("split-activity-" + label). + WithSentAt(time.Date(2026, 8, 19, 8, 0, 0, 0, time.UTC)). + Build() + message.SenderID = sql.NullInt64{Int64: senderID, Valid: true} + messageID, err := f.Store.UpsertMessage(message) + require.NoError(err) + messageIDs = append(messageIDs, messageID) + require.NoError(f.Store.ReplaceMessageRecipients( + messageID, "from", []int64{senderID}, []string{label})) + require.NoError(f.Store.ReplaceMessageRecipients( + messageID, "to", []int64{ownerParticipant}, []string{"Owner"})) + } + projector, err := activity.NewProjector(f.Store, activity.Options{ + Timezone: "UTC", BatchSize: 10, MaxDirectCounterparts: 1, + }) + require.NoError(err) + _, err = projector.RunOnce(ctx) + require.NoError(err) + survivor, err = f.Store.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = f.Store.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-activity-merge", Actor: "test", + }) + require.NoError(err) + postMergeMessage := f.NewMessage(). + WithSourceMessageID("split-activity-absorbed-after-merge"). + WithSentAt(time.Date(2026, 8, 19, 9, 0, 0, 0, time.UTC)). + Build() + postMergeMessage.SenderID = sql.NullInt64{Int64: absorbedParticipant, Valid: true} + postMergeMessageID, err := f.Store.UpsertMessage(postMergeMessage) + require.NoError(err) + messageIDs = append(messageIDs, postMergeMessageID) + require.NoError(f.Store.ReplaceMessageRecipients( + postMergeMessageID, "from", []int64{absorbedParticipant}, []string{"Absorbed"})) + require.NoError(f.Store.ReplaceMessageRecipients( + postMergeMessageID, "to", []int64{ownerParticipant}, []string{"Owner"})) + _, err = projector.RunOnce(ctx) + require.NoError(err) + + result, err := f.Store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: []int64{absorbedParticipant}, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-activity", Actor: "test", + }) + require.NoError(err) + rows, err := f.Store.DB().QueryContext(ctx, f.Store.Rebind(`SELECT message_id, person_id + FROM activity_event_persons WHERE message_id IN (?, ?, ?) ORDER BY message_id`), + messageIDs[0], messageIDs[1], messageIDs[2]) + require.NoError(err) + defer func() { require.NoError(rows.Close()) }() + linkedPeople := []int64{} + for rows.Next() { + var messageID, personID int64 + require.NoError(rows.Scan(&messageID, &personID)) + linkedPeople = append(linkedPeople, personID) + } + require.NoError(rows.Err()) + assert.ElementsMatch(t, + []int64{result.SourcePerson.ID, result.NewPerson.ID, result.NewPerson.ID}, linkedPeople) + for personID, want := range map[int64]int64{ + result.SourcePerson.ID: 1, + result.NewPerson.ID: 2, + } { + var count int64 + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT interaction_count + FROM person_contact_state WHERE person_id = ?`), personID).Scan(&count)) + assert.Equal(t, want, count) + } +} + +func TestSplitPersonMerge_RecomputesAbsorbedActivityRemovedByOwnerMerge(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := storetest.New(t) + ctx := context.Background() + ownerParticipant := f.EnsureParticipant( + "split-owner-merge@example.com", "Owner", "example.com") + absorbedParticipant := f.EnsureParticipant( + "split-owner-merge-absorbed@example.com", "Absorbed", "example.com") + require.NoError(f.Store.AddAccountIdentity( + f.Source.ID, "split-owner-merge@example.com", "test")) + owner, _, err := f.Store.CreatePersonFromParticipant(ownerParticipant) + require.NoError(err) + absorbed, _, err := f.Store.CreatePersonFromParticipant(absorbedParticipant) + require.NoError(err) + + message := f.NewMessage(). + WithSourceMessageID("split-owner-merge-activity"). + WithSentAt(time.Date(2026, 8, 20, 8, 0, 0, 0, time.UTC)). + Build() + message.SenderID = sql.NullInt64{Int64: absorbedParticipant, Valid: true} + messageID, err := f.Store.UpsertMessage(message) + require.NoError(err) + require.NoError(f.Store.ReplaceMessageRecipients( + messageID, "from", []int64{absorbedParticipant}, []string{"Absorbed"})) + require.NoError(f.Store.ReplaceMessageRecipients( + messageID, "to", []int64{ownerParticipant}, []string{"Owner"})) + projector, err := activity.NewProjector(f.Store, activity.Options{ + Timezone: "UTC", BatchSize: 10, MaxDirectCounterparts: 1, + }) + require.NoError(err) + _, err = projector.RunOnce(ctx) + require.NoError(err) + var linkedPersonID int64 + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT person_id + FROM activity_event_persons WHERE message_id = ?`), messageID).Scan(&linkedPersonID)) + assert.Equal(absorbed.ID, linkedPersonID) + + owner, err = f.Store.GetPersonContext(ctx, owner.ID) + require.NoError(err) + absorbed, err = f.Store.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := f.Store.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: owner.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: owner.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "split-owner-activity-merge", Actor: "test", + }) + require.NoError(err) + var mergedLinks int + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT COUNT(*) + FROM activity_event_persons WHERE message_id = ?`), messageID).Scan(&mergedLinks)) + assert.Zero(mergedLinks) + + split, err := f.Store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: []int64{absorbedParticipant}, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "split-owner-activity-split", Actor: "test", + }) + require.NoError(err) + require.NoError(f.Store.DB().QueryRowContext(ctx, f.Store.Rebind(`SELECT person_id + FROM activity_event_persons WHERE message_id = ?`), messageID).Scan(&linkedPersonID)) + assert.Equal(split.NewPerson.ID, linkedPersonID) +} + +func TestSplitPersonMerge_Rollback(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonSplitFixture(t) + ctx := context.Background() + if f.store.IsPostgreSQL() { + _, err := f.store.DB().ExecContext(ctx, ` + CREATE FUNCTION fail_person_split_name() RETURNS trigger AS $$ + BEGIN RAISE EXCEPTION 'forced person split failure'; END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER fail_person_split_name BEFORE UPDATE ON person_names + FOR EACH ROW EXECUTE FUNCTION fail_person_split_name();`) + require.NoError(err) + } else { + _, err := f.store.DB().ExecContext(ctx, `CREATE TRIGGER fail_person_split_name + BEFORE UPDATE ON person_names BEGIN + SELECT RAISE(ABORT, 'forced person split failure'); + END`) + require.NoError(err) + } + _, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: f.absorbedParticipants, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-rollback", Actor: "test", + }) + require.Error(err) + assert.Contains(err.Error(), "forced person split failure") + current, getErr := f.store.GetPersonContext(ctx, f.survivor.ID) + require.NoError(getErr) + assert.Equal(f.survivor.Revision, current.Revision) + assert.ElementsMatch(append([]int64{f.survivorParticipant}, f.absorbedParticipants...), + current.ParticipantIDs) + var splitCount int + require.NoError(f.store.DB().QueryRowContext(ctx, + `SELECT COUNT(*) FROM person_splits`).Scan(&splitCount)) + assert.Zero(splitCount) + var nameOwner int64 + require.NoError(f.store.DB().QueryRowContext(ctx, + f.store.Rebind(`SELECT person_id FROM person_names WHERE id = ?`), f.absorbedNameID).Scan(&nameOwner)) + assert.Equal(f.survivor.ID, nameOwner) +} + +func TestPersonSplitConcurrencySplitSplit(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonSplitFixture(t) + release := personOperationContentionBarrier(t, f.store, 2) + results := make(chan error, 2) + for _, key := range []string{"concurrent-split-a", "concurrent-split-b"} { + go func() { + _, err := f.store.SplitPersonMergeContext(context.Background(), store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: f.absorbedParticipants, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: key, Actor: "test", + }) + results <- err + }() + } + release() + errs := []error{<-results, <-results} + assert.Equal(1, countNilErrors(errs), "exactly one conflicting split may commit") + for _, err := range errs { + if err != nil { + assert.True(errors.Is(err, store.ErrPersonSplitRevision) || + errors.Is(err, store.ErrPersonSplitParticipants) || + errors.Is(err, store.ErrPersonMergeAlreadySplit), + "loser must report a typed stale-lineage error: %v", err) + } + } + current, err := f.store.GetPersonContext(context.Background(), f.survivor.ID) + require.NoError(err) + assert.Equal(f.survivor.Revision+1, current.Revision) + assertPersonSplitConcurrencyState(t, f.store, 1) +} + +func TestPersonSplitConcurrencyProfileUpdate(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonSplitFixture(t) + start := make(chan struct{}) + results := make(chan error, 2) + go func() { + <-start + _, err := f.store.SplitPersonMergeContext(context.Background(), store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: f.absorbedParticipants, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "concurrent-split-profile", Actor: "test", + }) + results <- err + }() + go func() { + <-start + _, err := f.store.UpdatePersonDisplayNameContext( + context.Background(), f.survivor.ID, f.survivor.Revision, new("Concurrent Name")) + results <- err + }() + close(start) + errs := []error{<-results, <-results} + assert.Equal(1, countNilErrors(errs), "split and stale profile update cannot both commit") + current, err := f.store.GetPersonContext(context.Background(), f.survivor.ID) + require.NoError(err) + assert.Equal(f.survivor.Revision+1, current.Revision) + var splitCount int + require.NoError(f.store.DB().QueryRow(`SELECT COUNT(*) FROM person_splits`).Scan(&splitCount)) + assert.Contains([]int{0, 1}, splitCount) + assertPersonSplitConcurrencyState(t, f.store, splitCount) +} + +func assertPersonSplitConcurrencyState(t *testing.T, st *store.Store, wantSplits int) { + t.Helper() + require := require.New(t) + assert := assert.New(t) + var splitCount, orphanRows, orphanParticipants int + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM person_splits`).Scan(&splitCount)) + assert.Equal(wantSplits, splitCount) + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM person_merge_rows row_record + LEFT JOIN person_merges merge_record ON merge_record.id = row_record.merge_id + WHERE merge_record.id IS NULL`).Scan(&orphanRows)) + require.NoError(st.DB().QueryRow(`SELECT COUNT(*) FROM person_merge_participants lineage + LEFT JOIN person_merges merge_record ON merge_record.id = lineage.merge_id + WHERE merge_record.id IS NULL`).Scan(&orphanParticipants)) + assert.Zero(orphanRows) + assert.Zero(orphanParticipants) + assertSQLiteForeignKeysClean(t, st) +} diff --git a/internal/store/persons.go b/internal/store/persons.go index 2e4f51b84..add7f42d3 100644 --- a/internal/store/persons.go +++ b/internal/store/persons.go @@ -17,6 +17,7 @@ var ( ErrPersonBindingConflict = errors.New("participant clusters belong to different persons") ErrPersonReferenced = errors.New("person is referenced by another profile") ErrPersonCardDAVPublished = errors.New("person has CardDAV publication state") + ErrPersonMergeActive = errors.New("person has active merge lineage") ) // PersonBindingConflictError reports the curated people that would be @@ -231,6 +232,45 @@ func (s *Store) deletePersonOnce(ctx context.Context, id, expectedRevision int64 if references > 0 { return fmt.Errorf("delete person %d: %w", id, ErrPersonReferenced) } + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) + FROM person_merge_review_candidates candidate + WHERE candidate.state = 'pending' AND EXISTS ( + SELECT 1 FROM person_attribute_values value + WHERE value.id IN ( + candidate.survivor_value_id, + candidate.absorbed_value_id, + candidate.resolution_value_id + ) + AND value.value_record_type = 'person' + AND value.value_record_id = ? + )`, id).Scan(&references); err != nil { + return fmt.Errorf("check merge-review references to person %d: %w", id, err) + } + if references > 0 { + return fmt.Errorf("delete person %d: %w", id, ErrPersonReferenced) + } + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM person_merges + WHERE current_person_id = ?`, id).Scan(&references); err != nil { + return fmt.Errorf("check active merge lineage for person %d: %w", id, err) + } + if references > 0 { + return fmt.Errorf("delete person %d: %w", id, ErrPersonMergeActive) + } + // A completed split releases the active lineage. Its review candidates + // are no longer actionable and must leave with the profile before the + // profile-value cascade reaches their RESTRICT references. + if _, err := tx.ExecContext(ctx, `DELETE FROM person_merge_review_candidates + WHERE survivor_person_id = ? + OR (merge_id IN (SELECT merge_record.id FROM person_merges merge_record + WHERE merge_record.current_person_id IS NULL) + AND EXISTS (SELECT 1 FROM person_attribute_values value + WHERE value.person_id = ? AND value.id IN ( + person_merge_review_candidates.survivor_value_id, + person_merge_review_candidates.absorbed_value_id, + person_merge_review_candidates.resolution_value_id + )))`, id, id); err != nil { + return fmt.Errorf("delete completed merge candidates for person %d: %w", id, err) + } if err := s.deleteIdentityMatchCandidatesForPersonTx(ctx, tx, id); err != nil { return err } diff --git a/internal/store/pg_maintenance_internal_test.go b/internal/store/pg_maintenance_internal_test.go index dd9325de8..c5db2edb5 100644 --- a/internal/store/pg_maintenance_internal_test.go +++ b/internal/store/pg_maintenance_internal_test.go @@ -18,22 +18,20 @@ import ( ) // skipUnlessPostgresInternal skips the calling internal (package store) test -// unless MSGVAULT_TEST_DB points at PostgreSQL. The maintenance escape hatch -// (SET LOCAL statement_timeout = 0) and the cascade-lock invariant are -// PostgreSQL-only: SQLite has no statement_timeout and no LOCK TABLE. +// unless MSGVAULT_TEST_DB points at PostgreSQL. func skipUnlessPostgresInternal(t *testing.T) string { t.Helper() testDB := os.Getenv("MSGVAULT_TEST_DB") if !strings.HasPrefix(testDB, "postgres://") && !strings.HasPrefix(testDB, "postgresql://") { - t.Skip("PG-only: maintenance timeout hatch / cascade lock invariant; requires MSGVAULT_TEST_DB pointing at PostgreSQL") + t.Skip("PG-only: requires MSGVAULT_TEST_DB pointing at PostgreSQL") } return testDB } // newPGStoreInternal opens a schema-isolated PostgreSQL store for an internal // (package store) test. It mirrors testutil.newPostgresTestStore but lives in -// package store so the test can reach unexported symbols (exclusiveLockTables, -// runMaintenance). The schema is dropped on cleanup. +// package store so tests can reach unexported symbols. The schema is dropped +// on cleanup. func newPGStoreInternal(t *testing.T, dbURL string) *Store { t.Helper() diff --git a/internal/store/postgres_integration_test.go b/internal/store/postgres_integration_test.go new file mode 100644 index 000000000..6b6f9e638 --- /dev/null +++ b/internal/store/postgres_integration_test.go @@ -0,0 +1,128 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +func TestPostgresPersonMergeParity(t *testing.T) { + st := testutil.NewTestStore(t) + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL integration database is not configured") + } + ctx := context.Background() + + t.Run("merge replay and stale revision rollback", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + survivor := mustPromotedPerson(t, st, "pg-parity-survivor@example.com", "Survivor") + absorbed := mustPromotedPerson(t, st, "pg-parity-absorbed@example.com", "Absorbed") + request := store.PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "pg-parity-merge", Actor: "test", + } + merged, err := st.MergePersonsContext(ctx, request) + require.NoError(err) + replayed, err := st.MergePersonsContext(ctx, request) + require.NoError(err) + assertJSONEquivalent(t, merged, replayed) + + staleSurvivor := mustPromotedPerson(t, st, "pg-stale-survivor@example.com", "Stale Survivor") + staleAbsorbed := mustPromotedPerson(t, st, "pg-stale-absorbed@example.com", "Stale Absorbed") + var mergeCountBefore int + require.NoError(st.DB().QueryRowContext(ctx, + `SELECT COUNT(*) FROM person_merges`).Scan(&mergeCountBefore)) + _, err = st.MergePersonsContext(ctx, store.PersonMergeRequest{ + SurvivorID: staleSurvivor.ID, AbsorbedID: staleAbsorbed.ID, + ExpectedSurvivorRevision: staleSurvivor.Revision + 1, + ExpectedAbsorbedRevision: staleAbsorbed.Revision, + IdempotencyKey: "pg-stale-merge", Actor: "test", + }) + require.ErrorIs(err, store.ErrPersonRevisionConflict) + var mergeCountAfter int + require.NoError(st.DB().QueryRowContext(ctx, + `SELECT COUNT(*) FROM person_merges`).Scan(&mergeCountAfter)) + assert.Equal(mergeCountBefore, mergeCountAfter) + _, err = st.GetPersonContext(ctx, staleAbsorbed.ID) + require.NoError(err, "failed merge must retain the absorbed profile") + }) + + t.Run("candidate decision", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonMergeInspectionFixture(t, "pg-parity-candidate") + require.True(f.store.IsPostgreSQL()) + require.Len(f.merge.ReviewCandidates, 1) + candidate, err := f.store.DecidePersonMergeCandidateContext(ctx, + store.PersonMergeCandidateDecisionRequest{ + CandidateID: f.merge.ReviewCandidates[0].ID, PersonID: f.person.ID, + ExpectedPersonRevision: f.person.Revision, + Decision: store.PersonMergeCandidateReject, Actor: "reviewer", + }) + require.NoError(err) + assert.Equal("rejected", candidate.State) + }) + + t.Run("identity link conflict payload", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + left := mustPromotedPerson(t, st, "pg-conflict-left@example.com", "Left") + right := mustPromotedPerson(t, st, "pg-conflict-right@example.com", "Right") + _, err := st.LinkParticipants(left.ParticipantIDs[0], right.ParticipantIDs[0]) + require.Error(err) + require.ErrorIs(err, store.ErrPersonBindingConflict) + var conflict *store.PersonBindingConflictError + require.ErrorAs(err, &conflict) + assert.ElementsMatch([]int64{left.ID, right.ID}, conflict.PersonIDs) + }) +} + +func TestPostgresPersonSplitParity(t *testing.T) { + t.Run("exact reversal and replay", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonSplitFixture(t) + if !f.store.IsPostgreSQL() { + t.Skip("PostgreSQL integration database is not configured") + } + request := store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: f.absorbedParticipants, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "pg-parity-exact-split", Actor: "test", + } + result, err := f.store.SplitPersonMergeContext(context.Background(), request) + require.NoError(err) + assert.True(result.ExactReversal) + replayed, err := f.store.SplitPersonMergeContext(context.Background(), request) + require.NoError(err) + assertJSONEquivalent(t, result, replayed) + }) + + t.Run("partial split", func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + f := newPersonSplitFixture(t) + if !f.store.IsPostgreSQL() { + t.Skip("PostgreSQL integration database is not configured") + } + result, err := f.store.SplitPersonMergeContext(context.Background(), store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: []int64{f.absorbedParticipants[0]}, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "pg-parity-partial-split", Actor: "test", + }) + require.NoError(err) + assert.False(result.ExactReversal) + assert.Equal([]int64{f.absorbedParticipants[0]}, result.NewPerson.ParticipantIDs) + assert.ElementsMatch([]int64{f.survivorParticipant, f.absorbedParticipants[1]}, + result.SourcePerson.ParticipantIDs) + }) +} diff --git a/internal/store/relationship_type_seed.go b/internal/store/relationship_type_seed.go index 14a0699f3..227561d26 100644 --- a/internal/store/relationship_type_seed.go +++ b/internal/store/relationship_type_seed.go @@ -39,8 +39,9 @@ var systemRelationshipTypes = []systemRelationshipTypeSeed{ ForwardLabel: "contact", ReverseLabel: "contact", IsSymmetric: true, IsCanonical: true, RelatedType: "contact"}, {Slug: "acquaintance", UniversalID: "dcee8148-7f92-4cdf-ac12-e33fb36a5ceb", ForwardLabel: "acquaintance", ReverseLabel: "acquaintance", IsSymmetric: true, IsCanonical: true, RelatedType: "acquaintance"}, - {Slug: "friend", UniversalID: "a1121fb7-8c6b-40d4-9fe1-1cb62dabbd88", - ForwardLabel: "friend", ReverseLabel: "friend", IsSymmetric: true, IsCanonical: true, RelatedType: "friend"}, + {Slug: relatedTypeFriend, UniversalID: "a1121fb7-8c6b-40d4-9fe1-1cb62dabbd88", + ForwardLabel: relatedTypeFriend, ReverseLabel: relatedTypeFriend, IsSymmetric: true, IsCanonical: true, + RelatedType: relatedTypeFriend}, {Slug: "met", UniversalID: "70cda530-be30-4cd5-8ef3-3ac197277d9d", ForwardLabel: "met", ReverseLabel: "met", IsSymmetric: true, IsCanonical: true, RelatedType: "met", Description: "The two people have met in person."}, diff --git a/internal/store/relationship_types.go b/internal/store/relationship_types.go index f1b0bb67b..a73924f7c 100644 --- a/internal/store/relationship_types.go +++ b/internal/store/relationship_types.go @@ -15,6 +15,8 @@ var ( ErrRelationshipTypeNotFound = errors.New("relationship type not found") ) +const relatedTypeFriend = "friend" + // relatedTypeValues is the set of TYPE parameter values that name a // person-to-person RELATION, in registry order. // @@ -38,7 +40,7 @@ var ( // repository vendors these CSVs yet). PR 8 depends on both and owns the test // that cross-checks this list against that snapshot once it exists. var relatedTypeValues = []string{ - "contact", "acquaintance", "friend", "met", "co-worker", "colleague", + "contact", "acquaintance", relatedTypeFriend, "met", "co-worker", "colleague", "co-resident", "neighbor", "child", "parent", "sibling", "spouse", "kin", "muse", "crush", "date", "sweetheart", "me", "agent", "emergency", } diff --git a/internal/store/schema.sql b/internal/store/schema.sql index 157b1f0b9..8eb1d7fbc 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -493,6 +493,8 @@ CREATE TABLE IF NOT EXISTS conversation_participants ( PRIMARY KEY (conversation_id, participant_id) ); +CREATE INDEX IF NOT EXISTS idx_conversation_participants_participant + ON conversation_participants(participant_id, conversation_id); -- Messages (unified across all platforms) CREATE TABLE IF NOT EXISTS messages ( @@ -1474,6 +1476,137 @@ CREATE INDEX IF NOT EXISTS idx_person_attribute_values_record_ref ON person_attribute_values(value_record_type, value_record_id) WHERE value_record_id IS NOT NULL; +-- Durable operation history for reversible person merges. Historical person IDs +-- deliberately are not foreign keys: absorbed roots are deleted, while the +-- immutable IDs remain part of the audit record. +CREATE TABLE IF NOT EXISTS person_merges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + idempotency_key TEXT NOT NULL UNIQUE, + request_hash TEXT NOT NULL, + survivor_person_id_at_merge INTEGER NOT NULL, + absorbed_person_id INTEGER NOT NULL, + current_person_id INTEGER REFERENCES persons(id) ON DELETE SET NULL, + survivor_uid TEXT NOT NULL, + absorbed_uid TEXT NOT NULL, + survivor_revision_before INTEGER NOT NULL, + absorbed_revision_before INTEGER NOT NULL, + survivor_revision_after INTEGER NOT NULL, + actor TEXT NOT NULL, + snapshot_version INTEGER NOT NULL, + snapshot_blob BLOB NOT NULL, + snapshot_sha256 TEXT NOT NULL, + result_json TEXT, + identity_revision INTEGER CHECK(identity_revision IS NULL OR identity_revision > 0), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (survivor_person_id_at_merge <> absorbed_person_id), + CHECK (length(idempotency_key) BETWEEN 1 AND 128), + CHECK (length(request_hash) = 64), + CHECK (snapshot_version > 0), + CHECK (length(snapshot_sha256) = 64) +); +CREATE INDEX IF NOT EXISTS idx_person_merges_current_person + ON person_merges(current_person_id, id DESC); + +-- Split headers retain historical source/new person IDs without foreign keys: +-- either resulting person can be absorbed by a later merge. +CREATE TABLE IF NOT EXISTS person_splits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + merge_id INTEGER NOT NULL REFERENCES person_merges(id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL UNIQUE, + request_hash TEXT NOT NULL, + source_person_id INTEGER NOT NULL, + new_person_id INTEGER NOT NULL, + new_person_uid TEXT NOT NULL, + source_revision_before INTEGER NOT NULL, + source_revision_after INTEGER NOT NULL, + actor TEXT NOT NULL, + is_exact_reversal BOOLEAN NOT NULL, + result_json TEXT, + identity_revision INTEGER CHECK(identity_revision IS NULL OR identity_revision > 0), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (source_person_id <> new_person_id), + CHECK (length(idempotency_key) BETWEEN 1 AND 128), + CHECK (length(request_hash) = 64) +); +CREATE INDEX IF NOT EXISTS idx_person_splits_merge + ON person_splits(merge_id, id); + +CREATE TABLE IF NOT EXISTS person_merge_participants ( + merge_id INTEGER NOT NULL REFERENCES person_merges(id) ON DELETE CASCADE, + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE RESTRICT, + origin_side TEXT NOT NULL CHECK(origin_side IN ('survivor', 'absorbed')), + split_id INTEGER REFERENCES person_splits(id) ON DELETE RESTRICT, + PRIMARY KEY (merge_id, participant_id) +); +CREATE INDEX IF NOT EXISTS idx_person_merge_participants_split + ON person_merge_participants(split_id) + WHERE split_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS person_merge_rows ( + merge_id INTEGER NOT NULL REFERENCES person_merges(id) ON DELETE CASCADE, + table_name TEXT NOT NULL, + original_row_id INTEGER, + original_row_key TEXT NOT NULL CHECK(original_row_key <> ''), + current_row_id INTEGER, + current_row_key TEXT, + origin_side TEXT NOT NULL CHECK(origin_side IN ('survivor', 'absorbed')), + provenance_kind TEXT NOT NULL CHECK(provenance_kind IN ( + 'participant_exact', 'absorbed_profile', 'derived', 'inbound_reference' + )), + participant_id INTEGER REFERENCES participants(id) ON DELETE RESTRICT, + action TEXT NOT NULL CHECK(action IN ( + 'moved', 'repointed', 'deduplicated', 'deleted_snapshot', 'recomputed' + )), + snapshot_path TEXT NOT NULL, + post_merge_row_json TEXT, + split_id INTEGER REFERENCES person_splits(id) ON DELETE RESTRICT, + UNIQUE (merge_id, table_name, original_row_key) +); +CREATE INDEX IF NOT EXISTS idx_person_merge_rows_split + ON person_merge_rows(split_id) + WHERE split_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_person_merge_rows_participant + ON person_merge_rows(participant_id, merge_id) + WHERE participant_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_person_merge_rows_current_id + ON person_merge_rows(table_name, current_row_id) + WHERE current_row_id IS NOT NULL AND split_id IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_merge_rows_current_key + ON person_merge_rows(table_name, current_row_key) + WHERE current_row_key IS NOT NULL AND split_id IS NULL; + +CREATE TABLE IF NOT EXISTS person_merge_row_person_refs ( + merge_id INTEGER NOT NULL, + table_name TEXT NOT NULL, + original_row_key TEXT NOT NULL, + column_name TEXT NOT NULL, + person_id INTEGER NOT NULL, + PRIMARY KEY (merge_id, table_name, original_row_key, column_name), + FOREIGN KEY (merge_id, table_name, original_row_key) + REFERENCES person_merge_rows(merge_id, table_name, original_row_key) + ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_person_merge_row_person_refs_person + ON person_merge_row_person_refs(person_id, merge_id); + +CREATE TABLE IF NOT EXISTS person_merge_review_candidates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + merge_id INTEGER NOT NULL REFERENCES person_merges(id) ON DELETE CASCADE, + survivor_person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + definition_id INTEGER NOT NULL REFERENCES attribute_definitions(id) ON DELETE RESTRICT, + survivor_value_id INTEGER NOT NULL REFERENCES person_attribute_values(id) ON DELETE RESTRICT, + absorbed_value_id INTEGER NOT NULL REFERENCES person_attribute_values(id) ON DELETE RESTRICT, + state TEXT NOT NULL DEFAULT 'pending' + CHECK(state IN ('pending', 'accepted', 'rejected')), + resolution_value_id INTEGER REFERENCES person_attribute_values(id) ON DELETE RESTRICT, + reviewed_by TEXT, + reviewed_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (merge_id, definition_id) +); +CREATE INDEX IF NOT EXISTS idx_person_merge_review_candidates_person + ON person_merge_review_candidates(survivor_person_id, state, id); + -- Organization-owned values mirror person_attribute_values field for field. -- object_type enforcement remains in the store because neither database can -- portably constrain a foreign row's object_type. diff --git a/internal/store/schema_pg.sql b/internal/store/schema_pg.sql index 27d95fab5..84dab7f7e 100644 --- a/internal/store/schema_pg.sql +++ b/internal/store/schema_pg.sql @@ -463,6 +463,8 @@ CREATE TABLE IF NOT EXISTS conversation_participants ( PRIMARY KEY (conversation_id, participant_id) ); +CREATE INDEX IF NOT EXISTS idx_conversation_participants_participant + ON conversation_participants(participant_id, conversation_id); CREATE TABLE IF NOT EXISTS messages ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, @@ -1485,6 +1487,137 @@ CREATE INDEX IF NOT EXISTS idx_person_attribute_values_record_ref ON person_attribute_values(value_record_type, value_record_id) WHERE value_record_id IS NOT NULL; +-- Durable operation history for reversible person merges. Historical person IDs +-- deliberately are not foreign keys: absorbed roots are deleted, while the +-- immutable IDs remain part of the audit record. +CREATE TABLE IF NOT EXISTS person_merges ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + idempotency_key TEXT NOT NULL UNIQUE, + request_hash TEXT NOT NULL, + survivor_person_id_at_merge BIGINT NOT NULL, + absorbed_person_id BIGINT NOT NULL, + current_person_id BIGINT REFERENCES persons(id) ON DELETE SET NULL, + survivor_uid TEXT NOT NULL, + absorbed_uid TEXT NOT NULL, + survivor_revision_before BIGINT NOT NULL, + absorbed_revision_before BIGINT NOT NULL, + survivor_revision_after BIGINT NOT NULL, + actor TEXT NOT NULL, + snapshot_version INTEGER NOT NULL, + snapshot_blob BYTEA NOT NULL, + snapshot_sha256 TEXT NOT NULL, + result_json TEXT, + identity_revision BIGINT CHECK(identity_revision IS NULL OR identity_revision > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (survivor_person_id_at_merge <> absorbed_person_id), + CHECK (length(idempotency_key) BETWEEN 1 AND 128), + CHECK (length(request_hash) = 64), + CHECK (snapshot_version > 0), + CHECK (length(snapshot_sha256) = 64) +); +CREATE INDEX IF NOT EXISTS idx_person_merges_current_person + ON person_merges(current_person_id, id DESC); + +-- Split headers retain historical source/new person IDs without foreign keys: +-- either resulting person can be absorbed by a later merge. +CREATE TABLE IF NOT EXISTS person_splits ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + merge_id BIGINT NOT NULL REFERENCES person_merges(id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL UNIQUE, + request_hash TEXT NOT NULL, + source_person_id BIGINT NOT NULL, + new_person_id BIGINT NOT NULL, + new_person_uid TEXT NOT NULL, + source_revision_before BIGINT NOT NULL, + source_revision_after BIGINT NOT NULL, + actor TEXT NOT NULL, + is_exact_reversal BOOLEAN NOT NULL, + result_json TEXT, + identity_revision BIGINT CHECK(identity_revision IS NULL OR identity_revision > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (source_person_id <> new_person_id), + CHECK (length(idempotency_key) BETWEEN 1 AND 128), + CHECK (length(request_hash) = 64) +); +CREATE INDEX IF NOT EXISTS idx_person_splits_merge + ON person_splits(merge_id, id); + +CREATE TABLE IF NOT EXISTS person_merge_participants ( + merge_id BIGINT NOT NULL REFERENCES person_merges(id) ON DELETE CASCADE, + participant_id BIGINT NOT NULL REFERENCES participants(id) ON DELETE RESTRICT, + origin_side TEXT NOT NULL CHECK(origin_side IN ('survivor', 'absorbed')), + split_id BIGINT REFERENCES person_splits(id) ON DELETE RESTRICT, + PRIMARY KEY (merge_id, participant_id) +); +CREATE INDEX IF NOT EXISTS idx_person_merge_participants_split + ON person_merge_participants(split_id) + WHERE split_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS person_merge_rows ( + merge_id BIGINT NOT NULL REFERENCES person_merges(id) ON DELETE CASCADE, + table_name TEXT NOT NULL, + original_row_id BIGINT, + original_row_key TEXT NOT NULL CHECK(original_row_key <> ''), + current_row_id BIGINT, + current_row_key TEXT, + origin_side TEXT NOT NULL CHECK(origin_side IN ('survivor', 'absorbed')), + provenance_kind TEXT NOT NULL CHECK(provenance_kind IN ( + 'participant_exact', 'absorbed_profile', 'derived', 'inbound_reference' + )), + participant_id BIGINT REFERENCES participants(id) ON DELETE RESTRICT, + action TEXT NOT NULL CHECK(action IN ( + 'moved', 'repointed', 'deduplicated', 'deleted_snapshot', 'recomputed' + )), + snapshot_path TEXT NOT NULL, + post_merge_row_json TEXT, + split_id BIGINT REFERENCES person_splits(id) ON DELETE RESTRICT, + UNIQUE (merge_id, table_name, original_row_key) +); +CREATE INDEX IF NOT EXISTS idx_person_merge_rows_split + ON person_merge_rows(split_id) + WHERE split_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_person_merge_rows_participant + ON person_merge_rows(participant_id, merge_id) + WHERE participant_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_person_merge_rows_current_id + ON person_merge_rows(table_name, current_row_id) + WHERE current_row_id IS NOT NULL AND split_id IS NULL; +CREATE INDEX IF NOT EXISTS idx_person_merge_rows_current_key + ON person_merge_rows(table_name, current_row_key) + WHERE current_row_key IS NOT NULL AND split_id IS NULL; + +CREATE TABLE IF NOT EXISTS person_merge_row_person_refs ( + merge_id BIGINT NOT NULL, + table_name TEXT NOT NULL, + original_row_key TEXT NOT NULL, + column_name TEXT NOT NULL, + person_id BIGINT NOT NULL, + PRIMARY KEY (merge_id, table_name, original_row_key, column_name), + FOREIGN KEY (merge_id, table_name, original_row_key) + REFERENCES person_merge_rows(merge_id, table_name, original_row_key) + ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_person_merge_row_person_refs_person + ON person_merge_row_person_refs(person_id, merge_id); + +CREATE TABLE IF NOT EXISTS person_merge_review_candidates ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + merge_id BIGINT NOT NULL REFERENCES person_merges(id) ON DELETE CASCADE, + survivor_person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + definition_id BIGINT NOT NULL REFERENCES attribute_definitions(id) ON DELETE RESTRICT, + survivor_value_id BIGINT NOT NULL REFERENCES person_attribute_values(id) ON DELETE RESTRICT, + absorbed_value_id BIGINT NOT NULL REFERENCES person_attribute_values(id) ON DELETE RESTRICT, + state TEXT NOT NULL DEFAULT 'pending' + CHECK(state IN ('pending', 'accepted', 'rejected')), + resolution_value_id BIGINT REFERENCES person_attribute_values(id) ON DELETE RESTRICT, + reviewed_by TEXT, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (merge_id, definition_id) +); +CREATE INDEX IF NOT EXISTS idx_person_merge_review_candidates_person + ON person_merge_review_candidates(survivor_person_id, state, id); + CREATE TABLE IF NOT EXISTS organization_attribute_values ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, diff --git a/internal/store/store.go b/internal/store/store.go index 28ccbdffc..a06498221 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -53,18 +53,21 @@ type Store struct { // Test-only seams into migration, backfill, and transaction paths, nil in // production and settable only from export_test.go. They belong to the // Store rather than the package because more than one Store can be - // migrating at once inside a single test binary — test fixtures build - // their schemas concurrently — and a hook installed by one test must - // never fire on another Store's migration. As package-level variables + // active at once inside a single test binary — test fixtures build their + // schemas concurrently — and a hook installed by one test must never fire + // on another Store's work. As package-level variables // they were also a data race between a test that installs one and any // concurrent migration that reads it. - initSchemaWindowHook func() - attributeSeedReadHook func(slug string) - contentChangedBackfillBatchHook func(fromID, toID int64) error - backfillFTSBatchErrHook func(fromID, toID int64) error - attachmentRoleRepairPreparedHook func() - cardDAVConflictResolveSnapshotHook func() - cardDAVTombstonePrepareSnapshotHook func() + initSchemaWindowHook func() + attributeSeedReadHook func(slug string) + contentChangedBackfillBatchHook func(fromID, toID int64) error + backfillFTSBatchErrHook func(fromID, toID int64) error + attachmentRoleRepairPreparedHook func() + cardDAVConflictResolveSnapshotHook func() + cardDAVTombstonePrepareSnapshotHook func() + identityMatchAcceptBeforeDecisionHook func() + personOperationBeforeIdentityLockHook func() + personMergeAfterSnapshotHook func() // Zero means "use the production batch size"; see // contentChangedBackfillBatch. Per-Store for the same reason. diff --git a/internal/store/subset.go b/internal/store/subset.go index a1335d60b..f1d317c33 100644 --- a/internal/store/subset.go +++ b/internal/store/subset.go @@ -2,10 +2,13 @@ package store import ( "database/sql" + "encoding/json" "errors" "fmt" "os" "path/filepath" + "sort" + "strconv" "strings" "time" @@ -16,15 +19,17 @@ import ( // CopyResult holds the summary of a subset copy operation. type CopyResult struct { - Messages int64 - Conversations int64 - Participants int64 - Labels int64 - Sources int64 - Organizations int64 - Employments int64 - DBSize int64 - Elapsed time.Duration + Messages int64 + Conversations int64 + Participants int64 + Labels int64 + Sources int64 + Organizations int64 + Employments int64 + PersonMergePackets int64 + OmittedPersonMergePackets int64 + DBSize int64 + Elapsed time.Duration } // ErrSubsetVCardResourcesRequireProfiles reports IncludeVCardResources @@ -93,9 +98,13 @@ func CopySubset( // the contact source recorded — custom properties, RELATED entries naming // people outside the subset, and residue no structured table represents — // which is why it needs its own authorization instead of riding -// IncludeProfiles. It requires IncludeProfiles, whose structured fields the -// body projects into; asking for the bodies without the profiles is an error -// rather than a silent no-op. +// IncludeProfiles. Complete merge packets also contain immutable merge-time +// snapshots, including values later redacted from live profile tables. Packets +// require IncludeAttributes in addition to IncludeProfiles and +// IncludeVCardResources; without it, scoped packets are counted as omitted. +// Native bodies require IncludeProfiles, whose structured fields they project +// into; asking for the bodies without the profiles is an error rather than a +// silent no-op. func CopySubsetWithOptions( srcDBPath, dstDir string, rowCount int, options CopySubsetOptions, ) (*CopyResult, error) { @@ -523,6 +532,10 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, return nil, err } result.Sources += extraSources + if _, err := copyByName(tx, "person_tracking", + `person_id IN (SELECT id FROM persons)`); err != nil { + return nil, fmt.Errorf("copy person tracking: %w", err) + } if options.IncludeVCardResources { if err := copyVCardResourceEnvelopes(tx); err != nil { return nil, err @@ -546,18 +559,24 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, if hasSensitive { sensitiveExpression = "is_sensitive" } + if _, err := tx.Exec(`CREATE TEMP TABLE provisional_attribute_definition_ids AS + SELECT universal_id, id FROM attribute_definitions`); err != nil { + return nil, fmt.Errorf("remember provisional subset attribute definitions: %w", err) + } // The destination is a brand-new archive and has no attribute values. // Remove its provisional seeds so source slugs cross the archive boundary // unchanged; post-copy InitSchema installs any missing seeds afterward. if _, err := tx.Exec(`DELETE FROM attribute_definitions`); err != nil { return nil, fmt.Errorf("clear provisional subset attribute definitions: %w", err) } - // Definitions are portable by universal_id, not their database-local - // numeric key. Reconcile every person definition into the destination, - // then map copied values through universal_id below. + // Definitions remain portable by universal_id. When a shipped definition + // already had the same provisional ID in both archives, retain that ID so + // merge snapshots and immutable results stay self-consistent. Custom or + // otherwise remapped definitions still receive destination-local IDs; a + // merge packet that embeds one is conservatively omitted below. if _, err := tx.Exec(fmt.Sprintf(` INSERT INTO attribute_definitions ( - universal_id, object_type, slug, label, description, + id, universal_id, object_type, slug, label, description, value_type, field_type, record_target, cardinality, display_order, is_required, ownership, ui_creatable, ui_editable, api_mutable, is_searchable, is_sensitive, is_audited, is_deletable, history_exempt, @@ -565,14 +584,18 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, created_at, updated_at ) SELECT - universal_id, object_type, slug, label, description, + CASE WHEN provisional.id = source.id THEN source.id ELSE NULL END, + source.universal_id, source.object_type, source.slug, source.label, + source.description, value_type, field_type, record_target, cardinality, display_order, is_required, ownership, ui_creatable, ui_editable, api_mutable, is_searchable, %s AS is_sensitive, is_audited, is_deletable, history_exempt, derived_source, options, vcard_property, is_active, revision, created_at, updated_at - FROM src.attribute_definitions - WHERE object_type IN ('person', 'organization') + FROM src.attribute_definitions source + LEFT JOIN provisional_attribute_definition_ids provisional + ON provisional.universal_id = source.universal_id + WHERE source.object_type IN ('person', 'organization') ON CONFLICT(universal_id) DO UPDATE SET object_type = excluded.object_type, slug = excluded.slug, @@ -682,6 +705,9 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, } } } + if err := copyPersonMergePackets(tx, options, result); err != nil { + return nil, err + } // Every table a native vCard mapping can own a row in — profile // components, relationships, employments, attribute values — has been // copied or deliberately skipped by now, so this is the first point at @@ -827,6 +853,823 @@ func copyData(tx *sql.Tx, rowCount int, options CopySubsetOptions) (*CopyResult, return result, nil } +// copyPersonMergePackets copies an audit packet only when the scoped archive +// contains every live person and participant needed to interpret it. The +// snapshot can contain all profile and native-card fields from both original +// people, so packets are limited to the strongest profile-data opt-in; an +// incomplete packet is less useful than no packet because it falsely promises +// that the historical merge can still be reversed. +func copyPersonMergePackets( + tx *sql.Tx, options CopySubsetOptions, result *CopyResult, +) error { + hasMerges, err := sourceTableExists(tx, "person_merges") + if err != nil { + return fmt.Errorf("check person merge schema: %w", err) + } + if !hasMerges { + return nil + } + if _, err := tx.Exec(`CREATE TEMP TABLE selected_person_merges ( + id INTEGER PRIMARY KEY + )`); err != nil { + return fmt.Errorf("create selected person merges: %w", err) + } + if options.IncludeProfiles && options.IncludeVCardResources { + if err := tx.QueryRow(`SELECT COUNT(*) FROM src.person_merges merge_record + WHERE merge_record.current_person_id IN (SELECT id FROM persons) + OR EXISTS (SELECT 1 FROM src.person_splits split_record + WHERE split_record.merge_id = merge_record.id + AND (split_record.source_person_id IN (SELECT id FROM persons) + OR split_record.new_person_id IN (SELECT id FROM persons))) + OR EXISTS (SELECT 1 FROM src.person_merge_participants lineage + JOIN person_participants binding + ON binding.participant_id = lineage.participant_id + WHERE lineage.merge_id = merge_record.id)`, + ).Scan(&result.PersonMergePackets); err != nil { + return fmt.Errorf("count scoped person merge packets: %w", err) + } + } + if options.IncludeProfiles && options.IncludeAttributes && options.IncludeVCardResources { + if _, err := tx.Exec(`INSERT INTO selected_person_merges (id) + SELECT merge_record.id FROM src.person_merges merge_record + WHERE ( + merge_record.current_person_id IN (SELECT id FROM persons) + OR EXISTS (SELECT 1 FROM src.person_splits split_record + WHERE split_record.merge_id = merge_record.id + AND (split_record.source_person_id IN (SELECT id FROM persons) + OR split_record.new_person_id IN (SELECT id FROM persons))) + OR EXISTS (SELECT 1 FROM src.person_merge_participants lineage + JOIN person_participants binding + ON binding.participant_id = lineage.participant_id + WHERE lineage.merge_id = merge_record.id) + ) + AND NOT EXISTS ( + SELECT 1 FROM src.person_merge_participants lineage + WHERE lineage.merge_id = merge_record.id + AND lineage.participant_id NOT IN (SELECT id FROM participants) + ) + AND NOT EXISTS ( + SELECT 1 FROM src.person_splits split_record + WHERE split_record.merge_id = merge_record.id + AND split_record.source_person_id NOT IN (SELECT id FROM persons) + ) + AND NOT EXISTS ( + SELECT 1 FROM src.person_splits split_record + WHERE split_record.merge_id = merge_record.id + AND split_record.new_person_id NOT IN (SELECT id FROM persons) + ) + AND NOT EXISTS ( + SELECT 1 FROM src.person_merge_review_candidates candidate + WHERE candidate.merge_id = merge_record.id + AND ( + candidate.survivor_person_id NOT IN (SELECT id FROM persons) + OR candidate.definition_id NOT IN ( + SELECT destination_definition.id + FROM src.attribute_definitions source_definition + JOIN attribute_definitions destination_definition + ON destination_definition.universal_id = source_definition.universal_id + WHERE source_definition.id = candidate.definition_id + ) + OR candidate.survivor_value_id NOT IN ( + SELECT id FROM person_attribute_values + ) + OR candidate.absorbed_value_id NOT IN ( + SELECT id FROM person_attribute_values + ) + OR (candidate.resolution_value_id IS NOT NULL + AND candidate.resolution_value_id NOT IN ( + SELECT id FROM person_attribute_values + )) + ) + ) + AND NOT EXISTS ( + SELECT 1 FROM src.person_merge_rows journal + JOIN src.person_attribute_values value + ON value.id = journal.original_row_id + JOIN src.attribute_definitions source_definition + ON source_definition.id = value.definition_id + JOIN attribute_definitions destination_definition + ON destination_definition.universal_id = source_definition.universal_id + WHERE journal.merge_id = merge_record.id + AND journal.table_name = 'person_attribute_values' + AND source_definition.id <> destination_definition.id + ) + AND NOT EXISTS ( + SELECT 1 FROM src.person_merge_rows journal + WHERE journal.merge_id = merge_record.id + AND journal.table_name = 'daily_note_entry_persons' + )`); err != nil { + return fmt.Errorf("select complete person merge packets: %w", err) + } + } + if options.IncludeProfiles && options.IncludeVCardResources { + if err := pruneIncompletePersonMergePacketsAndAliases(tx); err != nil { + return err + } + } + var selectedPackets int64 + if err := tx.QueryRow(`SELECT COUNT(*) FROM selected_person_merges`).Scan(&selectedPackets); err != nil { + return fmt.Errorf("count copied person merge packets: %w", err) + } + result.OmittedPersonMergePackets = result.PersonMergePackets - selectedPackets + result.PersonMergePackets = selectedPackets + // Operation results retain the identity revision committed in the source + // archive. Preserve that archive's current revision whenever a complete + // packet crosses the subset boundary, so replayed results never claim a + // revision ahead of the destination's cache authority. Keep the larger + // value if a future caller ever copies into a pre-populated destination. + if _, err := tx.Exec(`INSERT INTO archive_metadata (key, value) + SELECT 'identity_revision', source_revision.value + FROM src.archive_metadata source_revision + WHERE source_revision.key = 'identity_revision' + AND EXISTS (SELECT 1 FROM selected_person_merges) + ON CONFLICT(key) DO UPDATE SET value = CASE + WHEN CAST(excluded.value AS INTEGER) > CAST(archive_metadata.value AS INTEGER) + THEN excluded.value ELSE archive_metadata.value END`); err != nil { + return fmt.Errorf("preserve person merge identity revision: %w", err) + } + if _, err := copyByName(tx, "person_merges", + `id IN (SELECT id FROM selected_person_merges)`); err != nil { + return fmt.Errorf("copy person merges: %w", err) + } + if _, err := copyByName(tx, "person_splits", + `merge_id IN (SELECT id FROM selected_person_merges)`); err != nil { + return fmt.Errorf("copy person splits: %w", err) + } + if _, err := copyByName(tx, "person_merge_participants", + `merge_id IN (SELECT id FROM selected_person_merges)`); err != nil { + return fmt.Errorf("copy person merge participants: %w", err) + } + if _, err := copyByName(tx, "person_merge_rows", + `merge_id IN (SELECT id FROM selected_person_merges)`); err != nil { + return fmt.Errorf("copy person merge rows: %w", err) + } + if _, err := copyByName(tx, "person_merge_row_person_refs", + `merge_id IN (SELECT id FROM selected_person_merges)`); err != nil { + return fmt.Errorf("copy person merge row person references: %w", err) + } + if _, err := tx.Exec(`INSERT INTO person_merge_review_candidates ( + id, merge_id, survivor_person_id, definition_id, + survivor_value_id, absorbed_value_id, state, resolution_value_id, + reviewed_by, reviewed_at, created_at + ) SELECT + candidate.id, candidate.merge_id, candidate.survivor_person_id, + destination_definition.id, candidate.survivor_value_id, + candidate.absorbed_value_id, candidate.state, candidate.resolution_value_id, + candidate.reviewed_by, candidate.reviewed_at, candidate.created_at + FROM src.person_merge_review_candidates candidate + JOIN src.attribute_definitions source_definition + ON source_definition.id = candidate.definition_id + JOIN attribute_definitions destination_definition + ON destination_definition.universal_id = source_definition.universal_id + WHERE candidate.merge_id IN (SELECT id FROM selected_person_merges)`); err != nil { + return fmt.Errorf("copy person merge review candidates: %w", err) + } + if selectedPackets > 0 { + if err := reservePersonMergePacketIDs(tx); err != nil { + return err + } + } + return nil +} + +func reservePersonMergePacketIDs(tx *sql.Tx) error { + autoincrementTables := map[string]struct{}{} + tableRows, err := tx.Query(`SELECT name FROM sqlite_master + WHERE type = 'table' AND instr(upper(sql), 'AUTOINCREMENT') > 0 + ORDER BY name`) + if err != nil { + return fmt.Errorf("load subset AUTOINCREMENT tables: %w", err) + } + defer func() { _ = tableRows.Close() }() + for tableRows.Next() { + var table string + if err := tableRows.Scan(&table); err != nil { + return fmt.Errorf("scan subset AUTOINCREMENT table: %w", err) + } + autoincrementTables[table] = struct{}{} + } + if err := tableRows.Err(); err != nil { + return fmt.Errorf("iterate subset AUTOINCREMENT tables: %w", err) + } + if err := tableRows.Close(); err != nil { + return fmt.Errorf("close subset AUTOINCREMENT tables: %w", err) + } + + ceilings := map[string]int64{} + var historicalPersonID int64 + if err := tx.QueryRow(`SELECT MAX(person_id) FROM ( + SELECT survivor_person_id_at_merge AS person_id FROM person_merges + UNION ALL SELECT absorbed_person_id FROM person_merges + UNION ALL SELECT source_person_id FROM person_splits + UNION ALL SELECT new_person_id FROM person_splits + )`).Scan(&historicalPersonID); err != nil { + return fmt.Errorf("read historical person ID ceiling: %w", err) + } + ceilings["persons"] = historicalPersonID + + snapshotRows, err := tx.Query(`SELECT snapshot_blob, snapshot_sha256 + FROM person_merges ORDER BY id`) + if err != nil { + return fmt.Errorf("load copied person merge snapshots: %w", err) + } + defer func() { _ = snapshotRows.Close() }() + for snapshotRows.Next() { + var blob []byte + var sha256 string + if err := snapshotRows.Scan(&blob, &sha256); err != nil { + return fmt.Errorf("scan copied person merge snapshot: %w", err) + } + snapshot, err := decodePersonMergeSnapshot(blob, sha256) + if err != nil { + return fmt.Errorf("reserve copied person merge snapshot IDs: %w", err) + } + for _, person := range snapshot.Persons { + ceilings["persons"] = max(ceilings["persons"], person.ID) + } + for _, row := range snapshot.Rows { + if _, ok := autoincrementTables[row.TableName]; ok && row.RowID > 0 { + ceilings[row.TableName] = max(ceilings[row.TableName], row.RowID) + } + } + } + if err := snapshotRows.Err(); err != nil { + return fmt.Errorf("iterate copied person merge snapshots: %w", err) + } + if err := snapshotRows.Close(); err != nil { + return fmt.Errorf("close copied person merge snapshots: %w", err) + } + + tables := make([]string, 0, len(ceilings)) + for table := range ceilings { + if _, ok := autoincrementTables[table]; ok { + tables = append(tables, table) + } + } + sort.Strings(tables) + for _, table := range tables { + if err := advanceSubsetSQLiteSequence(tx, table, ceilings[table]); err != nil { + return err + } + } + return nil +} + +func advanceSubsetSQLiteSequence(tx *sql.Tx, table string, ceiling int64) error { + if _, err := tx.Exec(`UPDATE sqlite_sequence SET seq = CASE + WHEN seq < ? THEN ? ELSE seq END WHERE name = ?`, ceiling, ceiling, table); err != nil { + return fmt.Errorf("advance subset %s sequence: %w", table, err) + } + if _, err := tx.Exec(`INSERT INTO sqlite_sequence (name, seq) + SELECT ?, ? WHERE NOT EXISTS ( + SELECT 1 FROM sqlite_sequence WHERE name = ? + )`, table, ceiling, table); err != nil { + return fmt.Errorf("initialize subset %s sequence: %w", table, err) + } + return nil +} + +func pruneIncompletePersonMergePacketsAndAliases(tx *sql.Tx) error { + for { + var selectedBefore int64 + if err := tx.QueryRow(`SELECT COUNT(*) FROM selected_person_merges`).Scan( + &selectedBefore, + ); err != nil { + return fmt.Errorf("count selected person merge packets: %w", err) + } + if err := pruneIncompletePersonMergePackets(tx); err != nil { + return err + } + aliasResult, err := tx.Exec(`DELETE FROM person_uid_aliases + WHERE retired_uid IN ( + SELECT omitted.absorbed_uid FROM src.person_merges omitted + WHERE omitted.id NOT IN (SELECT id FROM selected_person_merges) + ) + AND retired_uid NOT IN ( + SELECT selected.absorbed_uid FROM src.person_merges selected + WHERE selected.id IN (SELECT id FROM selected_person_merges) + )`) + if err != nil { + return fmt.Errorf("remove incomplete person merge aliases: %w", err) + } + aliasesRemoved, err := aliasResult.RowsAffected() + if err != nil { + return fmt.Errorf("count removed incomplete person merge aliases: %w", err) + } + var selectedAfter int64 + if err := tx.QueryRow(`SELECT COUNT(*) FROM selected_person_merges`).Scan( + &selectedAfter, + ); err != nil { + return fmt.Errorf("recount selected person merge packets: %w", err) + } + if selectedBefore == selectedAfter && aliasesRemoved == 0 { + return nil + } + } +} + +func pruneIncompletePersonMergePackets(tx *sql.Tx) error { + mergeRows, err := tx.Query(`SELECT id FROM selected_person_merges ORDER BY id`) + if err != nil { + return fmt.Errorf("load selected person merge packets: %w", err) + } + defer func() { _ = mergeRows.Close() }() + mergeIDs := []int64{} + for mergeRows.Next() { + var mergeID int64 + if err := mergeRows.Scan(&mergeID); err != nil { + _ = mergeRows.Close() + return fmt.Errorf("scan selected person merge packet: %w", err) + } + mergeIDs = append(mergeIDs, mergeID) + } + if err := mergeRows.Err(); err != nil { + _ = mergeRows.Close() + return fmt.Errorf("iterate selected person merge packets: %w", err) + } + if err := mergeRows.Close(); err != nil { + return fmt.Errorf("close selected person merge packets: %w", err) + } + + for _, mergeID := range mergeIDs { + complete, err := personMergePacketRowsComplete(tx, mergeID) + if err != nil { + return err + } + if complete { + continue + } + if _, err := tx.Exec(`DELETE FROM selected_person_merges WHERE id = ?`, mergeID); err != nil { + return fmt.Errorf("omit incomplete person merge packet: %w", err) + } + } + return nil +} + +func personMergePacketRowsComplete(tx *sql.Tx, mergeID int64) (bool, error) { + var omittedSplitOwner int + if err := tx.QueryRow(`SELECT EXISTS ( + SELECT 1 FROM src.person_merge_participants lineage + JOIN src.person_splits split_record ON split_record.id = lineage.split_id + WHERE lineage.merge_id = ? + AND split_record.merge_id NOT IN (SELECT id FROM selected_person_merges) + )`, mergeID).Scan(&omittedSplitOwner); err != nil { + return false, fmt.Errorf("validate person merge split dependencies: %w", err) + } + if omittedSplitOwner != 0 { + return false, nil + } + var snapshotBlob []byte + var snapshotSHA256 string + if err := tx.QueryRow(`SELECT snapshot_blob, snapshot_sha256 + FROM src.person_merges WHERE id = ?`, mergeID).Scan( + &snapshotBlob, &snapshotSHA256, + ); err != nil { + return false, fmt.Errorf("load person merge packet snapshot: %w", err) + } + snapshot, err := decodePersonMergeSnapshot(snapshotBlob, snapshotSHA256) + if err != nil { + return false, fmt.Errorf("verify person merge packet %d snapshot: %w", mergeID, err) + } + rows, err := tx.Query(`SELECT table_name, current_row_id, current_row_key, + action, provenance_kind, split_id, snapshot_path + FROM src.person_merge_rows WHERE merge_id = ? + ORDER BY table_name, original_row_key`, mergeID) + if err != nil { + return false, fmt.Errorf("load person merge packet rows: %w", err) + } + defer func() { _ = rows.Close() }() + packetRows := []personMergePacketRow{} + for rows.Next() { + var row personMergePacketRow + if err := rows.Scan( + &row.table, &row.currentID, &row.currentKey, + &row.action, &row.provenance, &row.splitID, &row.snapshotPath, + ); err != nil { + _ = rows.Close() + return false, fmt.Errorf("scan person merge packet row: %w", err) + } + packetRows = append(packetRows, row) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return false, fmt.Errorf("iterate person merge packet rows: %w", err) + } + if err := rows.Close(); err != nil { + return false, fmt.Errorf("close person merge packet rows: %w", err) + } + complete, err := personMergeSnapshotRowsComplete(tx, snapshot, packetRows) + if err != nil || !complete { + return complete, err + } + for _, row := range packetRows { + if row.table == "daily_note_entry_persons" { + return false, nil + } + if row.table == "person_merges" { + if !row.currentID.Valid { + return false, nil + } + var exists int + err := tx.QueryRow(`SELECT 1 FROM selected_person_merges WHERE id = ?`, + row.currentID.Int64).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("validate referenced person merge packet: %w", err) + } + continue + } + if row.table == personMergeReviewCandidatesTableName { + if !row.currentID.Valid { + return false, nil + } + var exists int + err := tx.QueryRow(`SELECT 1 FROM src.person_merge_review_candidates candidate + WHERE candidate.id = ? AND candidate.merge_id IN ( + SELECT id FROM selected_person_merges + )`, row.currentID.Int64).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("validate referenced merge candidate packet: %w", err) + } + continue + } + if row.table == personRelationshipReviewsTableName && row.currentID.Valid { + var dependenciesComplete int + err := tx.QueryRow(`SELECT 1 FROM src.person_relationship_reviews review + WHERE review.id = ? + AND ( + review.matched_person_id IS NULL + OR review.matched_person_id IN (SELECT id FROM persons) + ) + AND ( + review.accepted_relationship_id IS NULL + OR review.accepted_relationship_id IN (SELECT id FROM person_relationships) + )`, row.currentID.Int64).Scan(&dependenciesComplete) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf( + "validate person relationship review packet dependencies: %w", err) + } + } + if row.splitID.Valid || row.action == "deleted_snapshot" || row.action == "recomputed" || + row.provenance == string(personMergeProvenanceDerived) { + continue + } + spec, ok := personMergeTableRegistry[row.table] + if !ok { + return false, fmt.Errorf("validate person merge packet: unregistered table %q", row.table) + } + where, args, err := personSplitCurrentRowWhere(spec, personSplitJournalRow{ + currentRowID: row.currentID, currentKey: row.currentKey, + }) + if err != nil { + return false, err + } + var exists int + err = tx.QueryRow(`SELECT 1 FROM `+personSplitIdentifier(row.table)+ + ` WHERE `+where+` LIMIT 1`, args...).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("validate person merge packet %s row: %w", row.table, err) + } + } + return true, nil +} + +type personMergePacketRow struct { + table, action, provenance, snapshotPath string + currentID sql.NullInt64 + currentKey sql.NullString + splitID sql.NullInt64 +} + +// personMergeSnapshotRowsComplete proves that every immutable snapshot row is +// inside the destination's selected-data closure. Journal rows alone are not +// sufficient: unchanged survivor-side rows are deliberately pruned from the +// journal, but remain in snapshot_blob with their full historical contents. +func personMergeSnapshotRowsComplete( + tx *sql.Tx, snapshot personMergeSnapshot, packetRows []personMergePacketRow, +) (bool, error) { + lineagePeople := make(map[int64]struct{}, len(snapshot.Persons)) + for _, person := range snapshot.Persons { + lineagePeople[person.ID] = struct{}{} + for _, participantID := range person.ParticipantIDs { + present, err := subsetRowIDExists(tx, "participants", participantID) + if err != nil || !present { + return present, err + } + } + } + journalByPath := make(map[string]personMergePacketRow, len(packetRows)) + for _, row := range packetRows { + journalByPath[row.snapshotPath] = row + } + for index, row := range snapshot.Rows { + if row.TableName == "daily_note_entry_persons" { + // These rows can embed message, owner, or note data outside the + // selected archive. The immutable snapshot cannot be redacted. + return false, nil + } + journal, hasJournal := journalByPath["rows/"+strconv.Itoa(index)] + present, err := personMergeSnapshotRowPresent(tx, row, journal, hasJournal) + if err != nil || !present { + return present, err + } + complete, err := personMergeSnapshotDependenciesComplete(tx, row, lineagePeople) + if err != nil || !complete { + return complete, err + } + } + return true, nil +} + +func personMergeSnapshotRowPresent( + tx *sql.Tx, row personMergeSnapshotRow, journal personMergePacketRow, hasJournal bool, +) (bool, error) { + if hasJournal && journal.action == "deleted_snapshot" { + return true, nil + } + keys := []string{row.RowKey} + if hasJournal && journal.currentKey.Valid && !journal.splitID.Valid { + keys = append(keys, journal.currentKey.String) + } + seen := map[string]struct{}{} + for _, key := range keys { + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + switch row.TableName { + case "person_merges": + id, ok, err := personMergeSnapshotSingleIntegerKey(key, "id") + if err != nil { + return false, err + } + if !ok { + continue + } + var present int + err = tx.QueryRow(`SELECT 1 FROM selected_person_merges WHERE id = ?`, id).Scan(&present) + if err == nil { + return true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("validate snapshot person merge dependency: %w", err) + } + case personMergeReviewCandidatesTableName: + id, ok, err := personMergeSnapshotSingleIntegerKey(key, "id") + if err != nil { + return false, err + } + if !ok { + continue + } + var present int + err = tx.QueryRow(`SELECT 1 FROM src.person_merge_review_candidates candidate + WHERE candidate.id = ? + AND candidate.merge_id IN (SELECT id FROM selected_person_merges)`, id).Scan(&present) + if err == nil { + return true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("validate snapshot review candidate dependency: %w", err) + } + default: + spec, ok := personMergeTableRegistry[row.TableName] + if !ok { + return false, fmt.Errorf("validate person merge snapshot: unregistered table %q", row.TableName) + } + where, args, err := personSplitRowKeyWhere(spec, key) + if err != nil { + return false, err + } + var present int + err = tx.QueryRow(`SELECT 1 FROM `+personSplitIdentifier(row.TableName)+ + ` WHERE `+where+` LIMIT 1`, args...).Scan(&present) + if err == nil { + return true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("validate snapshot %s row: %w", row.TableName, err) + } + } + } + return false, nil +} + +func personMergeSnapshotDependenciesComplete( + tx *sql.Tx, row personMergeSnapshotRow, lineagePeople map[int64]struct{}, +) (bool, error) { + spec, ok := personMergeTableRegistry[row.TableName] + if !ok { + return false, fmt.Errorf("validate person merge snapshot dependencies: unregistered table %q", row.TableName) + } + for _, reference := range spec.PersonReferences { + if reference.Kind == personMergeReferencePolymorphic && + personSplitSnapshotRowText(row, reference.KindColumn) != reference.KindValue { + continue + } + personID, present := personMergeSnapshotIntegerColumn(row, reference.IDColumn) + if !present { + continue + } + if _, lineage := lineagePeople[personID]; lineage { + continue + } + included, err := subsetRowIDExists(tx, "persons", personID) + if err != nil || !included { + return included, err + } + } + for _, catalog := range []struct { + table, column string + }{ + {table: "relationship_types", column: "relationship_type_id"}, + {table: "attribute_definitions", column: "definition_id"}, + } { + usesCatalog := (row.TableName == personRelationshipsTableName && + catalog.table == "relationship_types") || + ((row.TableName == personAttributeValuesTableName || + row.TableName == "organization_attribute_values" || + row.TableName == personMergeReviewCandidatesTableName) && + catalog.table == "attribute_definitions") + if !usesCatalog { + continue + } + sourceID, present := personMergeSnapshotIntegerColumn(row, catalog.column) + if !present { + continue + } + preserved, err := personMergeSnapshotCatalogIDPreserved(tx, catalog.table, sourceID) + if err != nil || !preserved { + return preserved, err + } + } + + dependencies := map[string][]struct { + column, table string + }{ + personRelationshipsTableName: {{"relationship_type_id", "relationship_types"}}, + personRelationshipReviewsTableName: {{"accepted_relationship_id", personRelationshipsTableName}}, + personAttributeValuesTableName: {{"definition_id", "attribute_definitions"}}, + "organization_attribute_values": { + {"organization_id", "organizations"}, {"definition_id", "attribute_definitions"}, + }, + personMergeReviewCandidatesTableName: { + {"merge_id", "selected_person_merges"}, + {"definition_id", "attribute_definitions"}, + {"survivor_value_id", personAttributeValuesTableName}, + {"absorbed_value_id", personAttributeValuesTableName}, + {"resolution_value_id", personAttributeValuesTableName}, + }, + "identity_match_candidate_redirects": { + {"retired_candidate_id", identityMatchCandidatesTableName}, + {"surviving_candidate_id", identityMatchCandidatesTableName}, + }, + identityMatchCandidateSourcesTableName: { + {"candidate_id", identityMatchCandidatesTableName}, {sourceIDColumnName, "sources"}, + }, + identityMatchEvidenceTableName: { + {"candidate_id", identityMatchCandidatesTableName}, + }, + identityMatchEvidenceSourcesTableName: { + {"evidence_id", identityMatchEvidenceTableName}, {sourceIDColumnName, "sources"}, + }, + "employments": { + {"organization_id", "organizations"}, {"address_id", "organization_addresses"}, + }, + } + for _, dependency := range dependencies[row.TableName] { + id, present := personMergeSnapshotIntegerColumn(row, dependency.column) + if !present { + continue + } + included, err := subsetRowIDExists(tx, dependency.table, id) + if err != nil || !included { + return included, err + } + } + + if row.TableName == identityMatchCandidatesTableName { + for _, side := range []string{"left", "right"} { + kind := personSplitSnapshotRowText(row, side+"_kind") + id, present := personMergeSnapshotIntegerColumn(row, side+"_id") + if !present { + return false, nil + } + table := map[string]string{ + "person": "persons", "participant": "participants", + "observation": "participant_contact_observations", + "contact_point": personContactPointsTableName, + }[kind] + if table == "" { + return false, nil + } + if kind == "person" { + if _, lineage := lineagePeople[id]; lineage { + continue + } + } + included, err := subsetRowIDExists(tx, table, id) + if err != nil || !included { + return included, err + } + } + } + for _, table := range []string{personContactPointsTableName, identityMatchCandidatesTableName} { + if row.TableName != table { + continue + } + serviceID, present := personMergeSnapshotIntegerColumn(row, "service_id") + if present { + preserved, err := personMergeSnapshotServiceIDPreserved(tx, serviceID) + if err != nil || !preserved { + return preserved, err + } + } + } + return true, nil +} + +func personMergeSnapshotServiceIDPreserved(tx *sql.Tx, sourceID int64) (bool, error) { + var destinationID int64 + err := tx.QueryRow(`SELECT destination_id FROM selected_profile_service_map + WHERE source_id = ?`, sourceID).Scan(&destinationID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("validate snapshot communication service mapping: %w", err) + } + // Snapshot blobs are immutable audit records. If the database-local ID was + // remapped, copying the blob would make a later split restore the wrong + // service; omit the packet rather than rewriting and re-signing history. + return destinationID == sourceID, nil +} + +func personMergeSnapshotCatalogIDPreserved( + tx *sql.Tx, table string, sourceID int64, +) (bool, error) { + identifier := personSplitIdentifier(table) + var destinationID int64 + err := tx.QueryRow(`SELECT destination.id + FROM src.`+identifier+` source + JOIN `+identifier+` destination + ON destination.universal_id = source.universal_id + WHERE source.id = ?`, sourceID).Scan(&destinationID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("validate snapshot %s mapping: %w", table, err) + } + return destinationID == sourceID, nil +} + +func personMergeSnapshotIntegerColumn(row personMergeSnapshotRow, name string) (int64, bool) { + for _, column := range row.Columns { + if column.Name == name && column.Value.Integer != nil { + return *column.Value.Integer, true + } + } + return 0, false +} + +func personMergeSnapshotSingleIntegerKey(encoded, name string) (int64, bool, error) { + var key []personMergeSnapshotColumn + if err := json.Unmarshal([]byte(encoded), &key); err != nil { + return 0, false, fmt.Errorf("decode person merge snapshot row key: %w", err) + } + if len(key) != 1 || key[0].Name != name || key[0].Value.Integer == nil { + return 0, false, nil + } + return *key[0].Value.Integer, true, nil +} + +func subsetRowIDExists(tx *sql.Tx, table string, id int64) (bool, error) { + var present int + err := tx.QueryRow(`SELECT 1 FROM `+personSplitIdentifier(table)+` WHERE id = ? LIMIT 1`, id).Scan(&present) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("validate subset %s dependency: %w", table, err) + } + return true, nil +} + type subsetServiceReference struct { table string where string @@ -917,7 +1760,7 @@ func reconcileSubsetCommunicationServices(tx *sql.Tx, includeProfiles bool) erro where: `participant_id IN (SELECT id FROM participants)`, }, subsetServiceReference{ - table: "identity_match_candidates", + table: identityMatchCandidatesTableName, where: subsetSourceIdentityMatchCandidateWhere, }, ) @@ -1134,7 +1977,7 @@ func copySubsetRelationships(tx *sql.Tx) error { // source_resource_uid arrived after the relationship tables; a source // that predates it has no column to read, and its rows carry no resource. edgeResourceUID, err := sourceColumnExpression( - tx, "person_relationships", "source_resource_uid", "edge") + tx, personRelationshipsTableName, "source_resource_uid", "edge") if err != nil { return err } @@ -1289,22 +2132,22 @@ func copyStructuredProfiles(tx *sql.Tx) (int64, error) { `participant_id IN (SELECT id FROM participants)`); err != nil { return 0, fmt.Errorf("copy participant_contact_observations: %w", err) } - hasCandidates, err := sourceTableExists(tx, "identity_match_candidates") + hasCandidates, err := sourceTableExists(tx, identityMatchCandidatesTableName) if err != nil { return 0, fmt.Errorf("check identity match candidate schema: %w", err) } if hasCandidates { if err := copyByNameWithCommunicationServiceMap( - tx, "identity_match_candidates", subsetSourceIdentityMatchCandidateWhere, + tx, identityMatchCandidatesTableName, subsetSourceIdentityMatchCandidateWhere, ); err != nil { return 0, fmt.Errorf("copy identity_match_candidates: %w", err) } - hasEvidence, err := sourceTableExists(tx, "identity_match_evidence") + hasEvidence, err := sourceTableExists(tx, identityMatchEvidenceTableName) if err != nil { return 0, fmt.Errorf("check identity match evidence schema: %w", err) } if hasEvidence { - if _, err := copyByName(tx, "identity_match_evidence", + if _, err := copyByName(tx, identityMatchEvidenceTableName, `candidate_id IN (SELECT id FROM identity_match_candidates)`); err != nil { return 0, fmt.Errorf("copy identity_match_evidence: %w", err) } @@ -1316,11 +2159,11 @@ func copyStructuredProfiles(tx *sql.Tx) (int64, error) { ownerKey string }{ { - table: "identity_match_candidate_sources", ownerTable: "identity_match_candidates", + table: identityMatchCandidateSourcesTableName, ownerTable: identityMatchCandidatesTableName, ownerKey: "candidate_id", }, { - table: "identity_match_evidence_sources", ownerTable: "identity_match_evidence", + table: identityMatchEvidenceSourcesTableName, ownerTable: identityMatchEvidenceTableName, ownerKey: "evidence_id", }, } @@ -1412,7 +2255,7 @@ var vcardMappingOwnerTables = map[string]struct{}{ "persons": {}, "person_names": {}, "person_contact_points": {}, "person_addresses": {}, "person_dates": {}, "person_categories": {}, "person_media": {}, "person_attribute_values": {}, "employments": {}, - "person_relationships": {}, "person_relationship_reviews": {}, + personRelationshipsTableName: {}, "person_relationship_reviews": {}, } // releaseVCardMappingsToMissingOwners drops, from every copied envelope, the diff --git a/internal/store/subset_test.go b/internal/store/subset_test.go index 6ca972d76..062ee6e9b 100644 --- a/internal/store/subset_test.go +++ b/internal/store/subset_test.go @@ -35,6 +35,1082 @@ func subsetPersonDefinition(slug string) AttributeDefinitionInput { } } +type subsetPersonMergeFixture struct { + mergeID, sourcePersonID int64 + absorbedUID string +} + +func seedSubsetPersonMerge( + t *testing.T, sourcePath string, missingSplitParticipant bool, +) subsetPersonMergeFixture { + t.Helper() + require := require.New(t) + ctx := context.Background() + st, err := Open(sourcePath) + require.NoError(err) + t.Cleanup(func() { require.NoError(st.Close()) }) + participantID := int64(3) + if missingSplitParticipant { + created, err := st.EnsureParticipant( + "subset-merge-hidden@example.com", "Hidden", "example.com") + require.NoError(err) + participantID = created + } + _, err = st.LinkParticipants(2, participantID) + require.NoError(err) + survivor, _, err := st.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := st.CreatePersonFromParticipant(2) + require.NoError(err) + _, err = st.AddPersonNameContext(ctx, absorbed.ID, PersonNameInput{ + NameKind: PersonNameFormatted, Formatted: new("Subset Absorbed"), + Envelope: ValueEnvelopeInput{Source: ProvenanceUser}, + }) + require.NoError(err) + for personID, value := range map[int64]string{ + survivor.ID: "email", absorbed.ID: "chat", + } { + _, err = st.SetPersonAttributeValueContext(ctx, PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + Value: AttributeValue{Type: AttributeValueText, Text: &value}, + Source: ProvenanceUser, + }) + require.NoError(err) + } + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-person-merge", Actor: "test", + }) + require.NoError(err) + split, err := st.SplitPersonMergeContext(ctx, PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: []int64{participantID}, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "subset-person-split", Actor: "test", + }) + require.NoError(err) + return subsetPersonMergeFixture{ + mergeID: merged.Merge.ID, sourcePersonID: split.SourcePerson.ID, + absorbedUID: absorbed.VCardUID, + } +} + +func TestSubsetCompletePersonMergePacket(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + sourceDir := t.TempDir() + destinationDir := filepath.Join(t.TempDir(), "subset") + sourcePath := createTestSourceDB(t, sourceDir, 4) + fixture := seedSubsetPersonMerge(t, sourcePath, false) + source, err := Open(sourcePath) + require.NoError(err) + sourceIdentityRevision, err := source.IdentityRevision() + require.NoError(err) + require.NoError(source.Close()) + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + destinationIdentityRevision, err := destination.IdentityRevision() + require.NoError(err) + assert.Positive(sourceIdentityRevision) + assert.Equal(sourceIdentityRevision, destinationIdentityRevision) + detail, err := destination.GetPersonMergeContext(ctx, fixture.mergeID) + require.NoError(err) + assert.Len(detail.Participants, 3) + assert.NotEmpty(detail.Rows) + assert.Len(detail.Splits, 1) + assert.Len(detail.ReviewCandidates, 1) + snapshot, err := destination.GetPersonMergeSnapshotContext( + ctx, fixture.mergeID) + require.NoError(err) + assert.NotEmpty(snapshot.JSON) + alias, err := destination.ResolveRetiredPersonUIDContext( + context.Background(), fixture.absorbedUID) + require.NoError(err) + require.NotNil(alias.SurvivingPersonID) + assert.Equal(fixture.sourcePersonID, *alias.SurvivingPersonID) +} + +func TestSubsetIncludesFullySplitPersonMergePacket(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := t.Context() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-closed-merge", Actor: "test", + }) + require.NoError(err) + split, err := source.SplitPersonMergeContext(ctx, PersonSplitRequest{ + SourcePersonID: merged.Person.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: merged.Person.Revision, + IdempotencyKey: "subset-close-merge", Actor: "test", + }) + require.NoError(err) + require.True(split.ExactReversal) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + copyResult, err := CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + assert.Equal(int64(1), copyResult.PersonMergePackets) + assert.Zero(copyResult.OmittedPersonMergePackets) + + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + detail, err := destination.GetPersonMergeContext(ctx, merged.Merge.ID) + require.NoError(err) + assert.Nil(detail.Merge.CurrentPersonID) + assert.Len(detail.Splits, 1) + _, err = destination.GetPersonContext(ctx, split.SourcePerson.ID) + require.NoError(err) + _, err = destination.GetPersonContext(ctx, split.NewPerson.ID) + require.NoError(err) +} + +func TestSubsetPersonMergePacketCanSplitAfterNewPersonCreation(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-roundtrip-merge", Actor: "test", + }) + require.NoError(err) + historicalAbsorbedID := absorbed.ID + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + copyResult, err := CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + assert.Equal(int64(1), copyResult.PersonMergePackets) + assert.Zero(copyResult.OmittedPersonMergePackets) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + + unrelatedParticipant, err := destination.EnsureParticipant( + "subset-roundtrip-unrelated@example.com", "Unrelated", "example.com") + require.NoError(err) + unrelated, created, err := destination.CreatePersonFromParticipantContext( + ctx, unrelatedParticipant) + require.NoError(err) + require.True(created) + assert.Greater(unrelated.ID, historicalAbsorbedID, + "new profiles must not reuse IDs embedded in imported merge lineage") + current, err := destination.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + split, err := destination.SplitPersonMergeContext(ctx, PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "subset-roundtrip-split", Actor: "test", + }) + require.NoError(err) + assert.True(split.ExactReversal) + assert.Contains(split.NewPerson.ParticipantIDs, int64(2)) +} + +func TestSubsetPersonMergePacketReservesRestorableRowIDs(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := t.Context() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + other, _, err := source.CreatePersonFromParticipant(3) + require.NoError(err) + historical, err := source.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ + SourcePersonID: survivor.ID, TargetPersonID: absorbed.ID, TypeSlug: "friend", + Source: ProvenanceUser, Actor: "test", + }) + require.NoError(err) + survivor, err = source.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = source.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-row-id-merge", Actor: "test", + }) + require.NoError(err) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + copyResult, err := CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + assert.Equal(int64(1), copyResult.PersonMergePackets) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + + current, err := destination.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + created, err := destination.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ + SourcePersonID: current.ID, TargetPersonID: other.ID, TypeSlug: "friend", + Source: ProvenanceUser, Actor: "test", + }) + require.NoError(err) + assert.Greater(created.ID, historical.ID, + "new rows must not reuse IDs embedded in imported merge snapshots") + current, err = destination.GetPersonContext(ctx, current.ID) + require.NoError(err) + split, err := destination.SplitPersonMergeContext(ctx, PersonSplitRequest{ + SourcePersonID: current.ID, MergeID: merged.Merge.ID, + ParticipantIDs: absorbed.ParticipantIDs, + ExpectedSourceRevision: current.Revision, + IdempotencyKey: "subset-row-id-split", Actor: "test", + }) + require.NoError(err) + restored, err := destination.GetPersonRelationshipContext(ctx, historical.ID) + require.NoError(err) + assert.ElementsMatch([]int64{split.SourcePerson.ID, split.NewPerson.ID}, + []int64{restored.SourcePersonID, restored.TargetPersonID}) +} + +func TestSubsetCorruptPersonMergeSnapshotIsReported(t *testing.T) { + require := require.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-corrupt-merge", Actor: "test", + }) + require.NoError(err) + _, err = source.DB().ExecContext(ctx, source.Rebind( + `UPDATE person_merges SET snapshot_blob = ? WHERE id = ?`), + []byte("corrupt"), merged.Merge.ID) + require.NoError(err) + require.NoError(source.Close()) + + _, err = CopySubsetWithOptions(sourcePath, filepath.Join(t.TempDir(), "subset"), 4, + CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.ErrorIs(err, ErrPersonMergeSnapshotCorrupt) +} + +func TestSubsetPersonMergePacketWithAbsorbedTrackingIsComplete(t *testing.T) { + require := require.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + _, err = source.SetPersonTrackingContext(ctx, absorbed.ID, true) + require.NoError(err) + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-absorbed-tracking", Actor: "test", + }) + require.NoError(err) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + _, err = destination.GetPersonMergeContext(ctx, merged.Merge.ID) + require.NoError(err) + tracking, err := destination.GetPersonTrackingContext(ctx, merged.Person.ID) + require.NoError(err) + require.True(tracking.Tracked) +} + +func TestSubsetPersonMergePacketRebuildsDerivedActivity(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(3) + require.NoError(err) + occurredAt := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC) + _, err = source.DB().ExecContext(ctx, source.Rebind(`INSERT INTO activity_events ( + message_id, ref_kind, source_id, channel, occurred_at, date_origin, + date_precision, timezone, utc_offset_minutes, local_date, direction, + owner_address, projected_last_modified, projected_identity_revision, + projected_account_identity_revision + ) VALUES (?, 'message', 1, 'email', ?, 'sent_at', 'timestamp', + 'UTC', 0, '2024-01-01', 'inbound', 'private-owner@example.com', ?, 1, 1)`), + 1, occurredAt, occurredAt) + require.NoError(err) + _, err = source.DB().ExecContext(ctx, source.Rebind(`INSERT INTO activity_event_persons + (message_id, person_id, role, evidence, local_date) + VALUES (?, ?, 'sender', 'direct', '2024-01-01')`), 1, survivor.ID) + require.NoError(err) + _, err = source.DB().ExecContext(ctx, source.Rebind(`INSERT INTO person_contact_state ( + person_id, first_contact_message_id, last_contact_message_id, + last_contact_owner, interaction_count + ) VALUES (?, 1, 1, 'private-owner@example.com', 1)`), survivor.ID) + require.NoError(err) + + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-derived-activity-merge", Actor: "test", + }) + require.NoError(err) + var snapshotBlob []byte + var snapshotSHA256 string + require.NoError(source.DB().QueryRowContext(ctx, source.Rebind(`SELECT + snapshot_blob, snapshot_sha256 FROM person_merges WHERE id = ?`), + merged.Merge.ID).Scan(&snapshotBlob, &snapshotSHA256)) + snapshot, err := decodePersonMergeSnapshot(snapshotBlob, snapshotSHA256) + require.NoError(err) + snapshotTables := make([]string, 0, len(snapshot.Rows)) + for _, row := range snapshot.Rows { + snapshotTables = append(snapshotTables, row.TableName) + } + assert.NotContains(snapshotTables, "activity_event_persons") + assert.NotContains(snapshotTables, "person_contact_state") + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 1, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + _, err = destination.GetPersonContext(ctx, merged.Person.ID) + require.NoError(err) + _, err = destination.GetPersonMergeContext(ctx, merged.Merge.ID) + require.NoError(err) +} + +func TestSubsetPersonMergePacketWithAbsorbedSplitResultIsOmitted(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + first := seedSubsetPersonMerge(t, sourcePath, false) + source, err := Open(sourcePath) + require.NoError(err) + detail, err := source.GetPersonMergeContext(ctx, first.mergeID) + require.NoError(err) + require.Len(detail.Splits, 1) + survivor, err := source.GetPersonContext(ctx, first.sourcePersonID) + require.NoError(err) + absorbed, err := source.GetPersonContext(ctx, detail.Splits[0].NewPersonID) + require.NoError(err) + _, err = source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-absorbed-split-result", Actor: "test", + }) + require.NoError(err) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + _, err = destination.GetPersonMergeContext(ctx, first.mergeID) + require.ErrorIs(err, ErrPersonMergeNotFound) + var danglingSplits int + require.NoError(destination.DB().QueryRowContext(ctx, `SELECT COUNT(*) + FROM person_splits split_record + LEFT JOIN persons source_person ON source_person.id = split_record.source_person_id + LEFT JOIN persons new_person ON new_person.id = split_record.new_person_id + WHERE source_person.id IS NULL OR new_person.id IS NULL`).Scan(&danglingSplits)) + assert.Zero(danglingSplits) +} + +func TestSubsetPersonMergePacketWithRemappedDefinitionIsOmitted(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + definitionInput := subsetPersonDefinition("merge_remapped_definition") + definition, err := source.CreateAttributeDefinitionContext(ctx, definitionInput) + require.NoError(err) + _, err = source.DB().ExecContext(ctx, + `UPDATE attribute_definitions SET id = 4242 WHERE id = ?`, definition.ID) + require.NoError(err) + for personID, value := range map[int64]string{ + survivor.ID: "survivor", absorbed.ID: "absorbed", + } { + _, err = source.SetPersonAttributeValueContext(ctx, PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: definitionInput.Slug, + Value: AttributeValue{Type: AttributeValueText, Text: &value}, + Source: ProvenanceUser, + }) + require.NoError(err) + } + survivor, err = source.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = source.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-remapped-definition", Actor: "test", + }) + require.NoError(err) + require.Len(merged.ReviewCandidates, 1) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + var mergeCount, candidateCount int + require.NoError(destination.DB().QueryRowContext(ctx, + `SELECT COUNT(*) FROM person_merges`).Scan(&mergeCount)) + require.NoError(destination.DB().QueryRowContext(ctx, + `SELECT COUNT(*) FROM person_merge_review_candidates`).Scan(&candidateCount)) + assert.Zero(mergeCount) + assert.Zero(candidateCount) + copiedDefinition, err := destination.GetAttributeDefinitionBySlugContext( + ctx, AttributeObjectPerson, definitionInput.Slug) + require.NoError(err) + assert.NotEqual(int64(4242), copiedDefinition.ID) +} + +func TestSubsetPersonMergePacketWithRemappedRelationshipTypeIsOmitted(t *testing.T) { + require := require.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + other, _, err := source.CreatePersonFromParticipant(3) + require.NoError(err) + relationshipType, err := source.CreateRelationshipTypeContext(ctx, + RelationshipTypeInput{ + Slug: "subset-remapped-relationship", ForwardLabel: "knows", + ReverseLabel: "known by", + }) + require.NoError(err) + _, err = source.DB().ExecContext(ctx, + `UPDATE relationship_types SET id = 4242 WHERE id = ?`, relationshipType.ID) + require.NoError(err) + _, err = source.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ + SourcePersonID: absorbed.ID, TargetPersonID: other.ID, + TypeSlug: relationshipType.Slug, Source: ProvenanceUser, Actor: "test", + }) + require.NoError(err) + survivor, err = source.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = source.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-remapped-relationship", Actor: "test", + }) + require.NoError(err) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + copiedType, err := destination.GetRelationshipTypeBySlugContext(ctx, relationshipType.Slug) + require.NoError(err) + require.NotEqual(int64(4242), copiedType.ID) + _, err = destination.GetPersonMergeContext(ctx, merged.Merge.ID) + require.ErrorIs(err, ErrPersonMergeNotFound) +} + +func TestSubsetPersonMergePacketWithRemappedOrganizationDefinitionIsOmitted(t *testing.T) { + require := require.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + organization, err := source.CreateOrganizationContext(ctx, OrganizationInput{ + Name: "Remapped Definition Org", Kind: OrganizationKindCompany, + }) + require.NoError(err) + definitionInput := subsetPersonDefinition("merge_remapped_org_definition") + definitionInput.ObjectType = AttributeObjectOrganization + definitionInput.ValueType = AttributeValueRecordReference + definitionInput.FieldType = AttributeFieldPerson + definitionInput.RecordTarget = new("person") + definition, err := source.CreateAttributeDefinitionContext(ctx, definitionInput) + require.NoError(err) + _, err = source.DB().ExecContext(ctx, + `UPDATE attribute_definitions SET id = 4242 WHERE id = ?`, definition.ID) + require.NoError(err) + _, err = source.SetOrganizationAttributeValueContext(ctx, OrganizationAttributeValueInput{ + OrganizationID: organization.ID, DefinitionSlug: definitionInput.Slug, + Value: AttributeValue{ + Type: AttributeValueRecordReference, RecordType: new("person"), + RecordID: &absorbed.ID, + }, + Source: ProvenanceUser, + }) + require.NoError(err) + _, err = source.AddEmploymentContext(ctx, EmploymentInput{ + PersonID: survivor.ID, OrganizationID: organization.ID, + Title: new("Engineer"), Source: ProvenanceUser, + }) + require.NoError(err) + survivor, err = source.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = source.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-remapped-org-definition", Actor: "test", + }) + require.NoError(err) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + copiedDefinition, err := destination.GetAttributeDefinitionBySlugContext( + ctx, AttributeObjectOrganization, definitionInput.Slug) + require.NoError(err) + require.NotEqual(int64(4242), copiedDefinition.ID) + _, err = destination.GetPersonMergeContext(ctx, merged.Merge.ID) + require.ErrorIs(err, ErrPersonMergeNotFound) +} + +func TestSubsetPersonMergePacketWithRemappedServiceIsOmitted(t *testing.T) { + require := require.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + var sourceServiceID int64 + require.NoError(source.DB().QueryRowContext(ctx, + `SELECT id FROM communication_services WHERE slug = 'whatsapp'`).Scan(&sourceServiceID)) + _, err = source.DB().ExecContext(ctx, `UPDATE communication_services + SET slug = 'subset-merge-custom-chat', display_label = 'Subset Merge Custom Chat', + normalization = 'lower', is_system = FALSE + WHERE id = ?`, sourceServiceID) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + serviceSlug := "subset-merge-custom-chat" + _, err = source.AddPersonContactPointContext(ctx, absorbed.ID, PersonContactPointInput{ + AddressKind: ContactAddressUsername, ServiceSlug: &serviceSlug, + OriginalValue: "absorbed-user", + Envelope: ValueEnvelopeInput{Source: ProvenanceUser}, + }) + require.NoError(err) + survivor, err = source.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = source.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-remapped-service", Actor: "test", + }) + require.NoError(err) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + copiedService, err := destination.ResolveCommunicationServiceContext(ctx, serviceSlug) + require.NoError(err) + require.NotEqual(sourceServiceID, copiedService.ID) + _, err = destination.GetPersonMergeContext(ctx, merged.Merge.ID) + require.ErrorIs(err, ErrPersonMergeNotFound) +} + +func TestSubsetIncompletePersonMergePacketIsOmitted(t *testing.T) { + require := require.New(t) + sourceDir := t.TempDir() + destinationDir := filepath.Join(t.TempDir(), "subset") + sourcePath := createTestSourceDB(t, sourceDir, 4) + fixture := seedSubsetPersonMerge(t, sourcePath, true) + _, err := CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + _, err = destination.GetPersonContext(context.Background(), fixture.sourcePersonID) + require.NoError(err) + for _, table := range []string{ + "person_merges", "person_merge_participants", "person_merge_rows", + "person_merge_review_candidates", "person_splits", + } { + var count int + require.NoError(destination.DB().QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM `+table).Scan(&count)) + assert.Zero(t, count, table) + } + _, err = destination.ResolveRetiredPersonUIDContext( + context.Background(), fixture.absorbedUID) + assert.ErrorIs(t, err, ErrPersonUIDAliasNotFound) +} + +func TestSubsetMergeAliasIsOmittedWithoutAttributes(t *testing.T) { + require := require.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + fixture := seedSubsetPersonMerge(t, sourcePath, false) + destinationDir := filepath.Join(t.TempDir(), "subset") + result, err := CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, IncludeVCardResources: true, + }) + require.NoError(err) + assert.Zero(t, result.PersonMergePackets) + assert.Equal(t, int64(1), result.OmittedPersonMergePackets) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + + _, err = destination.GetPersonMergeContext(ctx, fixture.mergeID) + require.ErrorIs(err, ErrPersonMergeNotFound) + _, err = destination.ResolveRetiredPersonUIDContext(ctx, fixture.absorbedUID) + require.ErrorIs(err, ErrPersonUIDAliasNotFound, + "a merge-created alias must not outlive its omitted lineage packet") +} + +func TestSubsetPersonMergePacketWithMissingRelationshipDependencyIsOmitted(t *testing.T) { + require := require.New(t) + sourceDir := t.TempDir() + destinationDir := filepath.Join(t.TempDir(), "subset") + sourcePath := createTestSourceDB(t, sourceDir, 4) + ctx := context.Background() + st, err := Open(sourcePath) + require.NoError(err) + hiddenParticipant, err := st.EnsureParticipant( + "subset-merge-outside@example.com", "Outside", "example.com") + require.NoError(err) + hidden, _, err := st.CreatePersonFromParticipant(hiddenParticipant) + require.NoError(err) + absorbed, _, err := st.CreatePersonFromParticipant(2) + require.NoError(err) + _, err = st.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ + SourcePersonID: absorbed.ID, TargetPersonID: hidden.ID, TypeSlug: "friend", + Source: ProvenanceUser, Actor: "test", + }) + require.NoError(err) + survivor, _, err := st.CreatePersonFromParticipant(1) + require.NoError(err) + survivor, err = st.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = st.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := st.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-missing-relationship", Actor: "test", + }) + require.NoError(err) + absorbedUID := absorbed.VCardUID + require.NoError(st.Close()) + + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + _, err = destination.GetPersonMergeContext(ctx, merged.Merge.ID) + require.ErrorIs(err, ErrPersonMergeNotFound) + _, err = destination.ResolveRetiredPersonUIDContext(ctx, absorbedUID) + require.ErrorIs(err, ErrPersonUIDAliasNotFound) +} + +func TestSubsetPersonMergePacketWithUnchangedHiddenRelationshipIsOmitted(t *testing.T) { + require := require.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + hiddenParticipant, err := source.EnsureParticipant( + "subset-unchanged-hidden@example.com", "Hidden", "example.com") + require.NoError(err) + hidden, _, err := source.CreatePersonFromParticipant(hiddenParticipant) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + _, err = source.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ + SourcePersonID: survivor.ID, TargetPersonID: hidden.ID, TypeSlug: "acquaintance", + Source: ProvenanceUser, Actor: "test", + }) + require.NoError(err) + survivor, err = source.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = source.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-unchanged-hidden-relationship", Actor: "test", + }) + require.NoError(err) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + _, err = destination.GetPersonMergeContext(ctx, merged.Merge.ID) + require.ErrorIs(err, ErrPersonMergeNotFound) +} + +func TestSubsetPersonMergePacketWithMissingRelationshipReviewDependencyIsOmitted(t *testing.T) { + for _, dependency := range []string{"matched_person", "accepted_relationship"} { + t.Run(dependency, func(t *testing.T) { + require := require.New(t) + ctx := context.Background() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + hiddenParticipantA, err := source.EnsureParticipant( + "subset-review-hidden-a@example.com", "Hidden A", "example.com") + require.NoError(err) + hiddenParticipantB, err := source.EnsureParticipant( + "subset-review-hidden-b@example.com", "Hidden B", "example.com") + require.NoError(err) + hiddenA, _, err := source.CreatePersonFromParticipant(hiddenParticipantA) + require.NoError(err) + hiddenB, _, err := source.CreatePersonFromParticipant(hiddenParticipantB) + require.NoError(err) + survivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + + var matchedPersonID, acceptedRelationshipID sql.NullInt64 + switch dependency { + case "matched_person": + matchedPersonID = sql.NullInt64{Int64: hiddenA.ID, Valid: true} + case "accepted_relationship": + edge, err := source.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ + SourcePersonID: hiddenA.ID, TargetPersonID: hiddenB.ID, + TypeSlug: "friend", Source: ProvenanceUser, Actor: "test", + }) + require.NoError(err) + acceptedRelationshipID = sql.NullInt64{Int64: edge.ID, Valid: true} + } + _, err = source.DB().ExecContext(ctx, `INSERT INTO person_relationship_reviews ( + person_id, raw_related_value, raw_related_type, value_kind, + matched_person_id, accepted_relationship_id, source + ) VALUES (?, ?, 'friend', 'text', ?, ?, 'system')`, + absorbed.ID, dependency, matchedPersonID, acceptedRelationshipID) + require.NoError(err) + survivor, err = source.GetPersonContext(ctx, survivor.ID) + require.NoError(err) + absorbed, err = source.GetPersonContext(ctx, absorbed.ID) + require.NoError(err) + merged, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-review-" + dependency, Actor: "test", + }) + require.NoError(err) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + _, err = destination.GetPersonMergeContext(ctx, merged.Merge.ID) + require.ErrorIs(err, ErrPersonMergeNotFound) + }) + } +} + +func TestSubsetPersonMergePacketWithOmittedPriorMergeIsOmitted(t *testing.T) { + require := require.New(t) + sourceDir := t.TempDir() + destinationDir := filepath.Join(t.TempDir(), "subset") + sourcePath := createTestSourceDB(t, sourceDir, 4) + first := seedSubsetPersonMerge(t, sourcePath, true) + ctx := context.Background() + st, err := Open(sourcePath) + require.NoError(err) + current, err := st.GetPersonContext(ctx, first.sourcePersonID) + require.NoError(err) + third, _, err := st.CreatePersonFromParticipant(3) + require.NoError(err) + current, err = st.GetPersonContext(ctx, current.ID) + require.NoError(err) + third, err = st.GetPersonContext(ctx, third.ID) + require.NoError(err) + second, err := st.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: third.ID, AbsorbedID: current.ID, + ExpectedSurvivorRevision: third.Revision, + ExpectedAbsorbedRevision: current.Revision, + IdempotencyKey: "subset-dependent-merge", Actor: "test", + }) + require.NoError(err) + _, err = st.SplitPersonMergeContext(ctx, PersonSplitRequest{ + SourcePersonID: second.Person.ID, MergeID: second.Merge.ID, + ParticipantIDs: current.ParticipantIDs, + ExpectedSourceRevision: second.Person.Revision, + IdempotencyKey: "subset-dependent-split", Actor: "test", + }) + require.NoError(err) + require.NoError(st.Close()) + + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + for _, mergeID := range []int64{first.mergeID, second.Merge.ID} { + _, err = destination.GetPersonMergeContext(ctx, mergeID) + assert.ErrorIs(t, err, ErrPersonMergeNotFound) + } +} + +func TestSubsetPersonMergePacketWithOmittedSplitOwnerIsOmitted(t *testing.T) { + require := require.New(t) + ctx := t.Context() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + source, err := Open(sourcePath) + require.NoError(err) + extraAbsorbedParticipant, err := source.EnsureParticipant( + "subset-split-extra@example.com", "Extra", "example.com") + require.NoError(err) + _, err = source.DB().ExecContext(ctx, `INSERT INTO message_recipients + (message_id, participant_id, recipient_type) VALUES (1, ?, 'cc')`, + extraAbsorbedParticipant) + require.NoError(err) + _, err = source.LinkParticipants(3, extraAbsorbedParticipant) + require.NoError(err) + hiddenParticipant, err := source.EnsureParticipant( + "subset-split-hidden@example.com", "Hidden", "example.com") + require.NoError(err) + hidden, _, err := source.CreatePersonFromParticipant(hiddenParticipant) + require.NoError(err) + outerSurvivor, _, err := source.CreatePersonFromParticipant(1) + require.NoError(err) + innerSurvivor, _, err := source.CreatePersonFromParticipant(2) + require.NoError(err) + innerAbsorbed, _, err := source.CreatePersonFromParticipant(3) + require.NoError(err) + _, err = source.AddPersonRelationshipContext(ctx, PersonRelationshipInput{ + SourcePersonID: outerSurvivor.ID, TargetPersonID: hidden.ID, + TypeSlug: "friend", Source: ProvenanceUser, Actor: "test", + }) + require.NoError(err) + outerSurvivor, err = source.GetPersonContext(ctx, outerSurvivor.ID) + require.NoError(err) + innerMerge, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: innerSurvivor.ID, AbsorbedID: innerAbsorbed.ID, + ExpectedSurvivorRevision: innerSurvivor.Revision, + ExpectedAbsorbedRevision: innerAbsorbed.Revision, + IdempotencyKey: "subset-split-inner-merge", Actor: "test", + }) + require.NoError(err) + outerMerge, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: outerSurvivor.ID, AbsorbedID: innerMerge.Person.ID, + ExpectedSurvivorRevision: outerSurvivor.Revision, + ExpectedAbsorbedRevision: innerMerge.Person.Revision, + IdempotencyKey: "subset-split-outer-merge", Actor: "test", + }) + require.NoError(err) + _, err = source.SplitPersonMergeContext(ctx, PersonSplitRequest{ + SourcePersonID: outerMerge.Person.ID, MergeID: outerMerge.Merge.ID, + ParticipantIDs: innerAbsorbed.ParticipantIDs, + ExpectedSourceRevision: outerMerge.Person.Revision, + IdempotencyKey: "subset-split-outer-partial", Actor: "test", + }) + require.NoError(err) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + result, err := CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + assert.Equal(t, int64(2), result.OmittedPersonMergePackets) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + for _, mergeID := range []int64{innerMerge.Merge.ID, outerMerge.Merge.ID} { + _, err = destination.GetPersonMergeContext(ctx, mergeID) + require.ErrorIs(err, ErrPersonMergeNotFound) + } +} + +func TestSubsetPersonMergePacketsPruneAliasDependenciesToFixedPoint(t *testing.T) { + require := require.New(t) + ctx := t.Context() + sourcePath := createTestSourceDB(t, t.TempDir(), 4) + first := seedSubsetPersonMerge(t, sourcePath, true) + source, err := Open(sourcePath) + require.NoError(err) + survivor, err := source.GetPersonContext(ctx, first.sourcePersonID) + require.NoError(err) + absorbed, _, err := source.CreatePersonFromParticipant(3) + require.NoError(err) + absorbedUID := absorbed.VCardUID + second, err := source.MergePersonsContext(ctx, PersonMergeRequest{ + SurvivorID: survivor.ID, AbsorbedID: absorbed.ID, + ExpectedSurvivorRevision: survivor.Revision, + ExpectedAbsorbedRevision: absorbed.Revision, + IdempotencyKey: "subset-alias-dependent-merge", Actor: "test", + }) + require.NoError(err) + require.NoError(source.Close()) + + destinationDir := filepath.Join(t.TempDir(), "subset") + _, err = CopySubsetWithOptions(sourcePath, destinationDir, 4, CopySubsetOptions{ + IncludeIdentity: true, IncludeProfiles: true, + IncludeAttributes: true, IncludeVCardResources: true, + }) + require.NoError(err) + destination, err := Open(filepath.Join(destinationDir, "msgvault.db")) + require.NoError(err) + t.Cleanup(func() { require.NoError(destination.Close()) }) + for _, mergeID := range []int64{first.mergeID, second.Merge.ID} { + _, err = destination.GetPersonMergeContext(ctx, mergeID) + require.ErrorIs(err, ErrPersonMergeNotFound) + } + for _, retiredUID := range []string{first.absorbedUID, absorbedUID} { + _, err = destination.ResolveRetiredPersonUIDContext(ctx, retiredUID) + require.ErrorIs(err, ErrPersonUIDAliasNotFound) + } +} + // createTestSourceDB creates a source database with schema and test // data. Returns the path to the database. func createTestSourceDB(t *testing.T, dir string, msgCount int) string { @@ -154,8 +1230,9 @@ func createTestSourceDB(t *testing.T, dir string, msgCount int) string { func seedAcceptedSubsetParticipantLink(t *testing.T, srcDB string) int64 { t.Helper() + require := require.New(t) st, err := Open(srcDB) - require.NoError(t, err, "open source store") + require.NoError(err, "open source store") defer func() { _ = st.Close() }() candidate, created, err := st.UpsertIdentityMatchCandidateContext( @@ -170,13 +1247,13 @@ func seedAcceptedSubsetParticipantLink(t *testing.T, srcDB string) int64 { Source: ProvenanceArchiveObservation, }, ) - require.NoError(t, err, "create identity match candidate") - require.True(t, created, "identity match candidate must be new") + require.NoError(err, "create identity match candidate") + require.True(created, "identity match candidate must be new") accepted, _, err := st.AcceptIdentityMatchCandidateContext( context.Background(), candidate.ID, "system", nil, ) - require.NoError(t, err, "accept identity match candidate") - require.Equal(t, IdentityMatchStateAccepted, accepted.State) + require.NoError(err, "accept identity match candidate") + require.Equal(IdentityMatchStateAccepted, accepted.State) return accepted.ID } @@ -3656,7 +4733,6 @@ func TestCopySubsetCopiesRelationshipsFromSourcesWithoutResourceColumn(t *testin require.NotEmpty(reviews) assert.Nil(reviews[0].SourceResourceUID) } - func TestCopySubsetReleasesReviewMappingsWhoseAcceptedEdgeWasFiltered(t *testing.T) { require := require.New(t) assert := assert.New(t) diff --git a/internal/store/sync.go b/internal/store/sync.go index 1fc1d4a52..10b4d0504 100644 --- a/internal/store/sync.go +++ b/internal/store/sync.go @@ -782,7 +782,7 @@ func (s *Store) GetOrCreateSource(sourceType, identifier string) (*Source, error source.ID, DefaultCollectionName, ); err != nil { slog.Warn("failed to add source to default collection (self-heals on next InitSchema)", - "source_id", source.ID, + sourceIDColumnName, source.ID, "identifier", identifier, "error", err, ) diff --git a/internal/store/vcard_source_resource_rewrite.go b/internal/store/vcard_source_resource_rewrite.go index 1d7286e3d..54610fc1d 100644 --- a/internal/store/vcard_source_resource_rewrite.go +++ b/internal/store/vcard_source_resource_rewrite.go @@ -132,8 +132,8 @@ func (s *Store) bumpVCardSourceResourceOwnersTx( return err } relatedIDs, err := s.vcardSourceResourceIDsTx(ctx, tx, []sourceResourceColumn{ - {table: "person_relationships", column: "source_person_id"}, - {table: "person_relationships", column: "target_person_id"}, + {table: personRelationshipsTableName, column: "source_person_id"}, + {table: personRelationshipsTableName, column: "target_person_id"}, {table: "person_relationship_reviews", column: "person_id"}, }, sourceRef, sourceResourceUID) if err != nil { @@ -207,7 +207,7 @@ func (s *Store) rewriteVCardSourceResourceProvenanceTx( len(vcardOrganizationComponentTables)+3) tables = append(tables, vcardPersonComponentTables...) tables = append(tables, - "person_relationships", "person_relationship_reviews", + personRelationshipsTableName, "person_relationship_reviews", "participant_contact_observations", ) tables = append(tables, vcardOrganizationComponentTables...) @@ -215,7 +215,7 @@ func (s *Store) rewriteVCardSourceResourceProvenanceTx( set := "source_resource_uid = ?, updated_at = " + s.dialect.Now() // Edges carry their own compare-and-swap revision; the other rows // are versioned through their owner. - if table == "person_relationships" { + if table == personRelationshipsTableName { set += ", revision = revision + 1" } _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET `+set+` diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index ff2c69a28..37b51a84f 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -38,6 +38,31 @@ func TestGeneratedSavedViewStateRoundTripsCanonicalDefinition(t *testing.T) { assert.JSONEq(t, want, string(got)) } +func TestGeneratedPersonMergeRequiredResponseExposesProfiles(t *testing.T) { + requirements := require.New(t) + now := time.Date(2026, time.August, 22, 12, 0, 0, 0, time.UTC) + payload, err := json.Marshal(generated.PersonMergeRequiredError{ + ErrorData: "person_merge_required", + Message: "These identities belong to different profiles", + Profiles: []generated.PersonMergeProfile{{ + Etag: `"person-7-rev-3"`, + Person: generated.Person{ + ID: 7, Revision: 3, VcardUID: "person-7", + ParticipantIds: []int64{11}, CreatedAt: now, UpdatedAt: now, + }, + }}, + }) + requirements.NoError(err) + + var response generated.LinkIdentityParticipantsErrorResponse + requirements.NoError(json.Unmarshal(payload, &response)) + conflict := response.LinkIdentityParticipants_ErrorResponse_AnyOf + requirements.NotNil(conflict) + requirements.True(conflict.IsA()) + requirements.Len(conflict.A.Profiles, 1) + assert.Equal(t, int64(7), conflict.A.Profiles[0].Person.ID) +} + func TestGeneratedPersonFileGalleryContract(t *testing.T) { requirements := require.New(t) assertions := assert.New(t) @@ -85,6 +110,87 @@ func TestGeneratedPatchPersonCanClearDisplayName(t *testing.T) { assert.JSONEq(t, `{"display_name":null}`, string(encoded)) } +func TestGeneratedPersonMergeRoundTrip(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(http.MethodPost, r.Method) + assert.Equal("/api/v1/people/7/merge", r.URL.Path) + assert.Equal(`"person-7-r3", "person-9-r2"`, r.Header.Get("If-Match")) + assert.Equal("generated-client-merge", r.Header.Get("Idempotency-Key")) + var body generated.MergePersonRequest + if !assert.NoError(json.NewDecoder(r.Body).Decode(&body)) { + http.Error(w, "invalid merge request", http.StatusBadRequest) + return + } + assert.Equal(int64(9), body.AbsorbedPersonID) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `"person-7-r4"`) + _, _ = w.Write([]byte(`{ + "person":{ + "id":7,"vcard_uid":"survivor-uid","revision":4, + "participant_ids":[70,90], + "created_at":"2026-08-19T00:00:00Z", + "updated_at":"2026-08-19T00:01:00Z" + }, + "merge":{ + "id":12,"survivor_person_id":7,"absorbed_person_id":9, + "current_person_id":7,"survivor_vcard_uid":"survivor-uid", + "absorbed_vcard_uid":"absorbed-uid", + "survivor_revision_before":3,"absorbed_revision_before":2, + "survivor_revision_after":4,"actor":"user", + "snapshot_version":1, + "snapshot_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at":"2026-08-19T00:01:00Z" + }, + "review_candidates":[{ + "id":21,"merge_id":12,"person_id":7,"definition_id":4, + "survivor_value_id":31,"absorbed_value_id":32,"state":"pending", + "created_at":"2026-08-19T00:01:00Z" + }], + "identity_revision":42, + "cache_state":"ready" + }`)) + })) + t.Cleanup(server.Close) + client, err := New(server.URL) + require.NoError(err) + + response, err := client.MergePersonsWithResponse( + context.Background(), &generated.MergePersonsRequestOptions{ + PathParams: &generated.MergePersonsPath{ID: 7}, + Header: &generated.MergePersonsHeaders{ + IfMatch: `"person-7-r3", "person-9-r2"`, + IdempotencyKey: "generated-client-merge", + }, + Body: &generated.MergePersonsBody{AbsorbedPersonID: 9}, + }, + ) + require.NoError(err) + require.NotNil(response.JSON200) + assert.Equal(int64(7), response.JSON200.Person.ID) + assert.Equal(int64(12), response.JSON200.Merge.ID) + assert.Equal(int64(42), response.JSON200.IdentityRevision) + assert.Equal(generated.PersonMergeResultCacheStateReady, response.JSON200.CacheState) + require.Len(response.JSON200.ReviewCandidates, 1) + assert.Equal(int64(21), response.JSON200.ReviewCandidates[0].ID) + require.NotNil(response.Headers200) + assert.Equal(`"person-7-r4"`, response.Headers200.ETag) +} + +func TestGeneratedPersonMergeSnapshotPreservesArbitraryJSON(t *testing.T) { + want := `{ + "version":1, + "sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "snapshot":{"persons":[{"id":7}],"rows":{"person_names":[1,2]}} + }` + var snapshot generated.PersonMergeSnapshotResponse + require.NoError(t, json.Unmarshal([]byte(want), &snapshot)) + encoded, err := json.Marshal(snapshot) + require.NoError(t, err) + assert.JSONEq(t, want, string(encoded)) +} + func TestGeneratedDailyNotePersonIDsRequirePositiveValues(t *testing.T) { require.NoError(t, (generated.CreateDailyNoteEntryRequest{ Body: "note", diff --git a/pkg/client/generated/client.go b/pkg/client/generated/client.go index e267e22ba..c8f81de8d 100644 --- a/pkg/client/generated/client.go +++ b/pkg/client/generated/client.go @@ -643,6 +643,14 @@ type ClientInterface interface { SearchPersonFiles(ctx context.Context, options *SearchPersonFilesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchPersonFilesResponse, error) SearchPersonFilesWithResponse(ctx context.Context, options *SearchPersonFilesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchPersonFilesResp, error) + // MergePersons Merge one durable person profile into another + MergePersons(ctx context.Context, options *MergePersonsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*MergePersonsResponse, error) + MergePersonsWithResponse(ctx context.Context, options *MergePersonsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*MergePersonsResp, error) + + // ListPersonMerges List merge history for a durable person + ListPersonMerges(ctx context.Context, options *ListPersonMergesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonMergesResponse, error) + ListPersonMergesWithResponse(ctx context.Context, options *ListPersonMergesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonMergesResp, error) + // GetPersonStructuredProfile Get a person's current structured profile GetPersonStructuredProfile(ctx context.Context, options *GetPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonStructuredProfileResponse, error) GetPersonStructuredProfileWithResponse(ctx context.Context, options *GetPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonStructuredProfileResp, error) @@ -663,6 +671,10 @@ type ClientInterface interface { ListPersonRelationships(ctx context.Context, options *ListPersonRelationshipsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipsResponse, error) ListPersonRelationshipsWithResponse(ctx context.Context, options *ListPersonRelationshipsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipsResp, error) + // SplitPersonMerge Split absorbed participant lineage into a new person + SplitPersonMerge(ctx context.Context, options *SplitPersonMergeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SplitPersonMergeResponse, error) + SplitPersonMergeWithResponse(ctx context.Context, options *SplitPersonMergeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SplitPersonMergeResp, error) + // GetPersonTracking Get a person's tracking state GetPersonTracking(ctx context.Context, options *GetPersonTrackingRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonTrackingResponse, error) GetPersonTrackingWithResponse(ctx context.Context, options *GetPersonTrackingRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonTrackingResp, error) @@ -671,6 +683,18 @@ type ClientInterface interface { SetPersonTracking(ctx context.Context, options *SetPersonTrackingRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonTrackingResponse, error) SetPersonTrackingWithResponse(ctx context.Context, options *SetPersonTrackingRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonTrackingResp, error) + // DecidePersonMergeCandidate Accept or reject a person merge attribute candidate + DecidePersonMergeCandidate(ctx context.Context, options *DecidePersonMergeCandidateRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DecidePersonMergeCandidateResponse, error) + DecidePersonMergeCandidateWithResponse(ctx context.Context, options *DecidePersonMergeCandidateRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DecidePersonMergeCandidateResp, error) + + // GetPersonMerge Inspect one durable person merge + GetPersonMerge(ctx context.Context, options *GetPersonMergeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonMergeResponse, error) + GetPersonMergeWithResponse(ctx context.Context, options *GetPersonMergeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonMergeResp, error) + + // GetPersonMergeSnapshot Read and verify one person merge snapshot + GetPersonMergeSnapshot(ctx context.Context, options *GetPersonMergeSnapshotRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonMergeSnapshotResponse, error) + GetPersonMergeSnapshotWithResponse(ctx context.Context, options *GetPersonMergeSnapshotRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonMergeSnapshotResp, error) + // ListPersonRelationshipReviews List imported RELATED values awaiting review ListPersonRelationshipReviews(ctx context.Context, options *ListPersonRelationshipReviewsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipReviewsResponse, error) ListPersonRelationshipReviewsWithResponse(ctx context.Context, options *ListPersonRelationshipReviewsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipReviewsResp, error) @@ -10222,6 +10246,133 @@ func (c *Client) SearchPersonFiles(ctx context.Context, options *SearchPersonFil return responseParser(ctx, resp) } +// MergePersons Merge one durable person profile into another +func (c *Client) MergePersons(ctx context.Context, options *MergePersonsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*MergePersonsResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/merge", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*MergePersonsResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(MergePersonsErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "MergePersonsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(MergePersonsResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "MergePersonsResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/merge") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// ListPersonMerges List merge history for a durable person +func (c *Client) ListPersonMerges(ctx context.Context, options *ListPersonMergesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonMergesResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/merges", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonMergesResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListPersonMergesErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonMergesErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListPersonMergesResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonMergesResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/merges") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // GetPersonStructuredProfile Get a person's current structured profile func (c *Client) GetPersonStructuredProfile(ctx context.Context, options *GetPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonStructuredProfileResponse, error) { var err error @@ -10524,6 +10675,70 @@ func (c *Client) ListPersonRelationships(ctx context.Context, options *ListPerso return responseParser(ctx, resp) } +// SplitPersonMerge Split absorbed participant lineage into a new person +func (c *Client) SplitPersonMerge(ctx context.Context, options *SplitPersonMergeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SplitPersonMergeResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/split", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*SplitPersonMergeResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(SplitPersonMergeErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SplitPersonMergeErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(SplitPersonMergeResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SplitPersonMergeResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/split") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // GetPersonTracking Get a person's tracking state func (c *Client) GetPersonTracking(ctx context.Context, options *GetPersonTrackingRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonTrackingResponse, error) { var err error @@ -10651,6 +10866,196 @@ func (c *Client) SetPersonTracking(ctx context.Context, options *SetPersonTracki return responseParser(ctx, resp) } +// DecidePersonMergeCandidate Accept or reject a person merge attribute candidate +func (c *Client) DecidePersonMergeCandidate(ctx context.Context, options *DecidePersonMergeCandidateRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DecidePersonMergeCandidateResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-merge-candidates/{candidate_id}/decision", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*DecidePersonMergeCandidateResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(DecidePersonMergeCandidateErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DecidePersonMergeCandidateErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(DecidePersonMergeCandidateResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DecidePersonMergeCandidateResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-merge-candidates/{candidate_id}/decision") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// GetPersonMerge Inspect one durable person merge +func (c *Client) GetPersonMerge(ctx context.Context, options *GetPersonMergeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonMergeResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-merges/{merge_id}", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonMergeResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetPersonMergeErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(GetPersonMergeResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-merges/{merge_id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// GetPersonMergeSnapshot Read and verify one person merge snapshot +func (c *Client) GetPersonMergeSnapshot(ctx context.Context, options *GetPersonMergeSnapshotRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonMergeSnapshotResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-merges/{merge_id}/snapshot", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetPersonMergeSnapshotResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetPersonMergeSnapshotErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeSnapshotErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(GetPersonMergeSnapshotResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeSnapshotResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-merges/{merge_id}/snapshot") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // ListPersonRelationshipReviews List imported RELATED values awaiting review func (c *Client) ListPersonRelationshipReviews(ctx context.Context, options *ListPersonRelationshipReviewsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipReviewsResponse, error) { var err error diff --git a/pkg/client/generated/client_options.go b/pkg/client/generated/client_options.go index 184ab4894..3c07c2642 100644 --- a/pkg/client/generated/client_options.go +++ b/pkg/client/generated/client_options.go @@ -6361,6 +6361,121 @@ func (o *SearchPersonFilesRequestOptions) GetHeader() (map[string]string, error) return nil, nil } +// MergePersonsRequestOptions is the options needed to make a request to MergePersons. +type MergePersonsRequestOptions struct { + PathParams *MergePersonsPath + Body *MergePersonsBody + Header *MergePersonsHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *MergePersonsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *MergePersonsRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *MergePersonsRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *MergePersonsRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *MergePersonsRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// ListPersonMergesRequestOptions is the options needed to make a request to ListPersonMerges. +type ListPersonMergesRequestOptions struct { + PathParams *ListPersonMergesPath + Query *ListPersonMergesQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListPersonMergesRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListPersonMergesRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *ListPersonMergesRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListPersonMergesRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListPersonMergesRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // GetPersonStructuredProfileRequestOptions is the options needed to make a request to GetPersonStructuredProfile. type GetPersonStructuredProfileRequestOptions struct { PathParams *GetPersonStructuredProfilePath @@ -6608,6 +6723,68 @@ func (o *ListPersonRelationshipsRequestOptions) GetHeader() (map[string]string, return nil, nil } +// SplitPersonMergeRequestOptions is the options needed to make a request to SplitPersonMerge. +type SplitPersonMergeRequestOptions struct { + PathParams *SplitPersonMergePath + Body *SplitPersonMergeBody + Header *SplitPersonMergeHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *SplitPersonMergeRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *SplitPersonMergeRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *SplitPersonMergeRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *SplitPersonMergeRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *SplitPersonMergeRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + // GetPersonTrackingRequestOptions is the options needed to make a request to GetPersonTracking. type GetPersonTrackingRequestOptions struct { PathParams *GetPersonTrackingPath @@ -6705,6 +6882,156 @@ func (o *SetPersonTrackingRequestOptions) GetHeader() (map[string]string, error) return nil, nil } +// DecidePersonMergeCandidateRequestOptions is the options needed to make a request to DecidePersonMergeCandidate. +type DecidePersonMergeCandidateRequestOptions struct { + PathParams *DecidePersonMergeCandidatePath + Body *DecidePersonMergeCandidateBody + Header *DecidePersonMergeCandidateHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *DecidePersonMergeCandidateRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *DecidePersonMergeCandidateRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *DecidePersonMergeCandidateRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *DecidePersonMergeCandidateRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *DecidePersonMergeCandidateRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// GetPersonMergeRequestOptions is the options needed to make a request to GetPersonMerge. +type GetPersonMergeRequestOptions struct { + PathParams *GetPersonMergePath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetPersonMergeRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetPersonMergeRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetPersonMergeRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetPersonMergeRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetPersonMergeRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// GetPersonMergeSnapshotRequestOptions is the options needed to make a request to GetPersonMergeSnapshot. +type GetPersonMergeSnapshotRequestOptions struct { + PathParams *GetPersonMergeSnapshotPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetPersonMergeSnapshotRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetPersonMergeSnapshotRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetPersonMergeSnapshotRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetPersonMergeSnapshotRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetPersonMergeSnapshotRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // ListPersonRelationshipReviewsRequestOptions is the options needed to make a request to ListPersonRelationshipReviews. type ListPersonRelationshipReviewsRequestOptions struct { Query *ListPersonRelationshipReviewsQuery diff --git a/pkg/client/generated/client_with_response.go b/pkg/client/generated/client_with_response.go index 37eebcdeb..6b23f4904 100644 --- a/pkg/client/generated/client_with_response.go +++ b/pkg/client/generated/client_with_response.go @@ -8522,7 +8522,21 @@ func (c *Client) LinkIdentityParticipantsWithResponse(ctx context.Context, optio } } return out, nil - case 500: + case 409: + out.JSON409 = new(LinkIdentityParticipantsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "LinkIdentityParticipantsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) default: return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) @@ -13317,6 +13331,248 @@ func (c *Client) SearchPersonFilesWithResponse(ctx context.Context, options *Sea } } +// MergePersons Merge one durable person profile into another +func (c *Client) MergePersonsWithResponse(ctx context.Context, options *MergePersonsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*MergePersonsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/merge", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/merge") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &MergePersonsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(MergePersonsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "MergePersonsResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &MergePersonsResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(MergePersonsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "MergePersonsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(MergePersonsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "MergePersonsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(MergePersonsErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "MergePersonsErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(MergePersonsErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "MergePersonsErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(MergePersonsErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "MergePersonsErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(MergePersonsErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "MergePersonsErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// ListPersonMerges List merge history for a durable person +func (c *Client) ListPersonMergesWithResponse(ctx context.Context, options *ListPersonMergesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonMergesResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/merges", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/merges") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListPersonMergesResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListPersonMergesResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonMergesResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 404: + out.JSON404 = new(ListPersonMergesErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonMergesErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(ListPersonMergesErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonMergesErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(ListPersonMergesErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonMergesErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + // GetPersonStructuredProfile Get a person's current structured profile func (c *Client) GetPersonStructuredProfileWithResponse(ctx context.Context, options *GetPersonStructuredProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonStructuredProfileResp, error) { var err error @@ -13849,13 +14105,14 @@ func (c *Client) ListPersonRelationshipsWithResponse(ctx context.Context, option } } -// GetPersonTracking Get a person's tracking state -func (c *Client) GetPersonTrackingWithResponse(ctx context.Context, options *GetPersonTrackingRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonTrackingResp, error) { +// SplitPersonMerge Split absorbed participant lineage into a new person +func (c *Client) SplitPersonMergeWithResponse(ctx context.Context, options *SplitPersonMergeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SplitPersonMergeResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/tracking", - Method: "GET", - Options: options, + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/split", + Method: "POST", + Options: options, + ContentType: "application/json", } req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) @@ -13863,12 +14120,12 @@ func (c *Client) GetPersonTrackingWithResponse(ctx context.Context, options *Get return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/tracking") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/split") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &GetPersonTrackingResp{ + out := &SplitPersonMergeResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -13876,7 +14133,7 @@ func (c *Client) GetPersonTrackingWithResponse(ctx context.Context, options *Get switch resp.StatusCode { case 200: - out.JSON200 = new(GetPersonTrackingResponse) + out.JSON200 = new(SplitPersonMergeResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -13884,53 +14141,200 @@ func (c *Client) GetPersonTrackingWithResponse(ctx context.Context, options *Get StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonTrackingResponse", + TargetType: "SplitPersonMergeResponse", Body: bodyBytes, Err: err, } } } + out.Headers200 = &SplitPersonMergeResp200Headers{ + ETag: resp.Headers.Get("ETag"), + XNewPersonETag: resp.Headers.Get("X-New-Person-ETag"), + } return out, nil - case 404: - out.JSON404 = new(GetPersonTrackingErrorResponse) + case 400: + out.JSON400 = new(SplitPersonMergeErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { return out, &runtime.ResponseDecodeError{ StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonTrackingErrorResponse", + TargetType: "SplitPersonMergeErrorResponse", Body: bodyBytes, Err: err, } } } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) - case 503: - out.JSON503 = new(GetPersonTrackingErrorResponseJSON) + case 404: + out.JSON404 = new(SplitPersonMergeErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { return out, &runtime.ResponseDecodeError{ StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetPersonTrackingErrorResponseJSON", + TargetType: "SplitPersonMergeErrorResponseJSON", Body: bodyBytes, Err: err, } } } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) - default: - return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) - } -} - -// SetPersonTracking Replace a person's tracking state -func (c *Client) SetPersonTrackingWithResponse(ctx context.Context, options *SetPersonTrackingRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonTrackingResp, error) { - var err error + case 409: + out.JSON409 = new(SplitPersonMergeErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SplitPersonMergeErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(SplitPersonMergeErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SplitPersonMergeErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(SplitPersonMergeErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SplitPersonMergeErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(SplitPersonMergeErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SplitPersonMergeErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetPersonTracking Get a person's tracking state +func (c *Client) GetPersonTrackingWithResponse(ctx context.Context, options *GetPersonTrackingRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonTrackingResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/tracking", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/tracking") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetPersonTrackingResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetPersonTrackingResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonTrackingResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 404: + out.JSON404 = new(GetPersonTrackingErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonTrackingErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetPersonTrackingErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonTrackingErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// SetPersonTracking Replace a person's tracking state +func (c *Client) SetPersonTrackingWithResponse(ctx context.Context, options *SetPersonTrackingRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonTrackingResp, error) { + var err error reqParams := runtime.RequestOptionsParameters{ RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/tracking", Method: "PUT", @@ -14024,6 +14428,378 @@ func (c *Client) SetPersonTrackingWithResponse(ctx context.Context, options *Set } } +// DecidePersonMergeCandidate Accept or reject a person merge attribute candidate +func (c *Client) DecidePersonMergeCandidateWithResponse(ctx context.Context, options *DecidePersonMergeCandidateRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DecidePersonMergeCandidateResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-merge-candidates/{candidate_id}/decision", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-merge-candidates/{candidate_id}/decision") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &DecidePersonMergeCandidateResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(DecidePersonMergeCandidateResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DecidePersonMergeCandidateResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &DecidePersonMergeCandidateResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(DecidePersonMergeCandidateErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DecidePersonMergeCandidateErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(DecidePersonMergeCandidateErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DecidePersonMergeCandidateErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(DecidePersonMergeCandidateErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DecidePersonMergeCandidateErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(DecidePersonMergeCandidateErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DecidePersonMergeCandidateErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(DecidePersonMergeCandidateErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DecidePersonMergeCandidateErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(DecidePersonMergeCandidateErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DecidePersonMergeCandidateErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetPersonMerge Inspect one durable person merge +func (c *Client) GetPersonMergeWithResponse(ctx context.Context, options *GetPersonMergeRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonMergeResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-merges/{merge_id}", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-merges/{merge_id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetPersonMergeResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetPersonMergeResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(GetPersonMergeErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetPersonMergeErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(GetPersonMergeErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetPersonMergeErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetPersonMergeSnapshot Read and verify one person merge snapshot +func (c *Client) GetPersonMergeSnapshotWithResponse(ctx context.Context, options *GetPersonMergeSnapshotRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetPersonMergeSnapshotResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-merges/{merge_id}/snapshot", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-merges/{merge_id}/snapshot") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetPersonMergeSnapshotResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetPersonMergeSnapshotResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeSnapshotResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &GetPersonMergeSnapshotResp200Headers{ + CacheControl: resp.Headers.Get("Cache-Control"), + } + return out, nil + case 400: + out.JSON400 = new(GetPersonMergeSnapshotErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeSnapshotErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetPersonMergeSnapshotErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeSnapshotErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(GetPersonMergeSnapshotErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeSnapshotErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetPersonMergeSnapshotErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetPersonMergeSnapshotErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + // ListPersonRelationshipReviews List imported RELATED values awaiting review func (c *Client) ListPersonRelationshipReviewsWithResponse(ctx context.Context, options *ListPersonRelationshipReviewsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonRelationshipReviewsResp, error) { var err error diff --git a/pkg/client/generated/enums.go b/pkg/client/generated/enums.go index 7236a7b64..0ddf4925f 100644 --- a/pkg/client/generated/enums.go +++ b/pkg/client/generated/enums.go @@ -133,6 +133,23 @@ func (c CreateCommunicationServiceRequestScopePolicy) Validate() error { } } +type DecidePersonMergeCandidateRequestDecision string + +const ( + Accept DecidePersonMergeCandidateRequestDecision = "accept" + Reject DecidePersonMergeCandidateRequestDecision = "reject" +) + +// Validate checks if the DecidePersonMergeCandidateRequestDecision value is valid +func (d DecidePersonMergeCandidateRequestDecision) Validate() error { + switch d { + case Accept, Reject: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid DecidePersonMergeCandidateRequestDecision value, got: %v", d)) + } +} + type DiscoverEventType string const ( @@ -868,6 +885,40 @@ func (p PersonFileSearchRowContentState) Validate() error { } } +type PersonMergeResultCacheState string + +const ( + PersonMergeResultCacheStateReady PersonMergeResultCacheState = "ready" + PersonMergeResultCacheStateStale PersonMergeResultCacheState = "stale" +) + +// Validate checks if the PersonMergeResultCacheState value is valid +func (p PersonMergeResultCacheState) Validate() error { + switch p { + case PersonMergeResultCacheStateReady, PersonMergeResultCacheStateStale: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid PersonMergeResultCacheState value, got: %v", p)) + } +} + +type PersonSplitResultCacheState string + +const ( + PersonSplitResultCacheStateReady PersonSplitResultCacheState = "ready" + PersonSplitResultCacheStateStale PersonSplitResultCacheState = "stale" +) + +// Validate checks if the PersonSplitResultCacheState value is valid +func (p PersonSplitResultCacheState) Validate() error { + switch p { + case PersonSplitResultCacheStateReady, PersonSplitResultCacheStateStale: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid PersonSplitResultCacheState value, got: %v", p)) + } +} + type ProvenanceDirections string const ( diff --git a/pkg/client/generated/headers.go b/pkg/client/generated/headers.go index e306303a5..51c333d9b 100644 --- a/pkg/client/generated/headers.go +++ b/pkg/client/generated/headers.go @@ -123,6 +123,18 @@ func (p PatchPersonHeaders) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(p)) } +type MergePersonsHeaders struct { + // IfMatch Exactly two comma-separated strong person revision tags, one for each profile + IfMatch string `json:"If-Match" validate:"required"` + + // IdempotencyKey Opaque 1..128-byte retry key + IdempotencyKey string `json:"Idempotency-Key" validate:"required,max=128,min=1"` +} + +func (m MergePersonsHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(m)) +} + type PatchPersonStructuredProfileHeaders struct { // IfMatch Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. IfMatch string `json:"If-Match" validate:"required"` @@ -132,6 +144,27 @@ func (p PatchPersonStructuredProfileHeaders) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(p)) } +type SplitPersonMergeHeaders struct { + // IfMatch Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. + IfMatch string `json:"If-Match" validate:"required"` + + // IdempotencyKey Opaque 1..128-byte retry key + IdempotencyKey string `json:"Idempotency-Key" validate:"required,max=128,min=1"` +} + +func (s SplitPersonMergeHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(s)) +} + +type DecidePersonMergeCandidateHeaders struct { + // IfMatch Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. + IfMatch string `json:"If-Match" validate:"required"` +} + +func (d DecidePersonMergeCandidateHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + type DeletePersonRelationshipHeaders struct { // IfMatch Strong ETag returned by the latest person relationship read IfMatch string `json:"If-Match" validate:"required"` diff --git a/pkg/client/generated/paths.go b/pkg/client/generated/paths.go index 9118bcaf5..af8b977d1 100644 --- a/pkg/client/generated/paths.go +++ b/pkg/client/generated/paths.go @@ -460,6 +460,16 @@ type SearchPersonFilesPath struct { ID int64 `json:"id"` } +type MergePersonsPath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + +type ListPersonMergesPath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + type GetPersonStructuredProfilePath struct { // ID Durable person ID ID int64 `json:"id"` @@ -488,6 +498,11 @@ type ListPersonRelationshipsPath struct { ID int64 `json:"id"` } +type SplitPersonMergePath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + type GetPersonTrackingPath struct { // ID Durable person ID ID int64 `json:"id"` @@ -498,6 +513,33 @@ type SetPersonTrackingPath struct { ID int64 `json:"id"` } +type DecidePersonMergeCandidatePath struct { + // CandidateID Person merge review candidate ID + CandidateID int64 `json:"candidate_id" validate:"gte=1"` +} + +func (d DecidePersonMergeCandidatePath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + +type GetPersonMergePath struct { + // MergeID Durable person merge ID + MergeID int64 `json:"merge_id" validate:"gte=1"` +} + +func (g GetPersonMergePath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(g)) +} + +type GetPersonMergeSnapshotPath struct { + // MergeID Durable person merge ID + MergeID int64 `json:"merge_id" validate:"gte=1"` +} + +func (g GetPersonMergeSnapshotPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(g)) +} + type DeletePersonRelationshipPath struct { // ID Person relationship ID ID int64 `json:"id"` diff --git a/pkg/client/generated/payloads.go b/pkg/client/generated/payloads.go index 02c7d9a0a..0839ce808 100644 --- a/pkg/client/generated/payloads.go +++ b/pkg/client/generated/payloads.go @@ -132,10 +132,16 @@ type SetPersonAttributeBody = SetPersonAttributeRequest type SearchPersonFilesBody = PersonFileSearchHTTPRequest +type MergePersonsBody = MergePersonRequest + type PatchPersonStructuredProfileBody = PersonProfilePatchRequest +type SplitPersonMergeBody = SplitPersonRequest + type SetPersonTrackingBody = PutPersonTrackingRequest +type DecidePersonMergeCandidateBody = DecidePersonMergeCandidateRequest + type CreatePersonRelationshipBody = CreatePersonRelationshipRequest type PatchPersonRelationshipBody = PatchPersonRelationshipRequest diff --git a/pkg/client/generated/queries.go b/pkg/client/generated/queries.go index 0a5ed7c6f..c16704d7f 100644 --- a/pkg/client/generated/queries.go +++ b/pkg/client/generated/queries.go @@ -673,6 +673,14 @@ type ListPersonEmploymentsQuery struct { Offset *int64 `json:"offset,omitempty"` } +type ListPersonMergesQuery struct { + // Limit Maximum results + Limit *int64 `json:"limit,omitempty"` + + // Offset Results to skip + Offset *int64 `json:"offset,omitempty"` +} + type ListPersonRelationshipsQuery struct { IncludeEnded *bool `json:"include_ended,omitempty"` } diff --git a/pkg/client/generated/responses.go b/pkg/client/generated/responses.go index f720b492c..a5c1d1e8b 100644 --- a/pkg/client/generated/responses.go +++ b/pkg/client/generated/responses.go @@ -1205,7 +1205,47 @@ type GetHealthErrorResponse = ErrorResponse type LinkIdentityParticipantsResponse = IdentityLinkResponse -type LinkIdentityParticipantsErrorResponse = ErrorResponse +type LinkIdentityParticipantsErrorResponse struct { + LinkIdentityParticipants_ErrorResponse_AnyOf *LinkIdentityParticipants_ErrorResponse_AnyOf `json:"-"` +} + +func (r LinkIdentityParticipantsErrorResponse) Error() string { + return "unmapped client error" +} + +func (l LinkIdentityParticipantsErrorResponse) MarshalJSON() ([]byte, error) { + var parts []json.RawMessage + + { + b, err := runtime.MarshalJSON(l.LinkIdentityParticipants_ErrorResponse_AnyOf) + if err != nil { + return nil, fmt.Errorf("LinkIdentityParticipants_ErrorResponse_AnyOf marshal: %w", err) + } + parts = append(parts, b) + } + + return runtime.CoalesceOrMerge(parts...) +} + +func (l *LinkIdentityParticipantsErrorResponse) UnmarshalJSON(data []byte) error { + trim := bytes.TrimSpace(data) + if bytes.Equal(trim, []byte("null")) { + return nil + } + if len(trim) == 0 { + return fmt.Errorf("empty JSON input") + } + + if l.LinkIdentityParticipants_ErrorResponse_AnyOf == nil { + l.LinkIdentityParticipants_ErrorResponse_AnyOf = &LinkIdentityParticipants_ErrorResponse_AnyOf{} + } + + if err := runtime.UnmarshalJSON(data, l.LinkIdentityParticipants_ErrorResponse_AnyOf); err != nil { + return fmt.Errorf("LinkIdentityParticipants_ErrorResponse_AnyOf unmarshal: %w", err) + } + + return nil +} type ListIdentityMatchCandidatesResponse = IdentityMatchCandidatesResponse @@ -1215,7 +1255,43 @@ type AcceptIdentityMatchCandidateResponse = IdentityMatchAcceptResponse type AcceptIdentityMatchCandidateErrorResponse = ErrorResponse -type AcceptIdentityMatchCandidateErrorResponseJSON = ErrorResponse +type AcceptIdentityMatchCandidateErrorResponseJSON struct { + AcceptIdentityMatchCandidate_ErrorResponse_409_AnyOf *AcceptIdentityMatchCandidate_ErrorResponse_409_AnyOf `json:"-"` +} + +func (a AcceptIdentityMatchCandidateErrorResponseJSON) MarshalJSON() ([]byte, error) { + var parts []json.RawMessage + + { + b, err := runtime.MarshalJSON(a.AcceptIdentityMatchCandidate_ErrorResponse_409_AnyOf) + if err != nil { + return nil, fmt.Errorf("AcceptIdentityMatchCandidate_ErrorResponse_409_AnyOf marshal: %w", err) + } + parts = append(parts, b) + } + + return runtime.CoalesceOrMerge(parts...) +} + +func (a *AcceptIdentityMatchCandidateErrorResponseJSON) UnmarshalJSON(data []byte) error { + trim := bytes.TrimSpace(data) + if bytes.Equal(trim, []byte("null")) { + return nil + } + if len(trim) == 0 { + return fmt.Errorf("empty JSON input") + } + + if a.AcceptIdentityMatchCandidate_ErrorResponse_409_AnyOf == nil { + a.AcceptIdentityMatchCandidate_ErrorResponse_409_AnyOf = &AcceptIdentityMatchCandidate_ErrorResponse_409_AnyOf{} + } + + if err := runtime.UnmarshalJSON(data, a.AcceptIdentityMatchCandidate_ErrorResponse_409_AnyOf); err != nil { + return fmt.Errorf("AcceptIdentityMatchCandidate_ErrorResponse_409_AnyOf unmarshal: %w", err) + } + + return nil +} type AcceptIdentityMatchCandidateErrorResponseJSON503 = ErrorResponse @@ -1841,6 +1917,28 @@ func (s *SearchPersonFilesErrorResponseJSON503) UnmarshalJSON(data []byte) error return nil } +type MergePersonsResponse = PersonMergeResult + +type MergePersonsErrorResponse = ErrorResponse + +type MergePersonsErrorResponseJSON = ErrorResponse + +type MergePersonsErrorResponseJSON409 = ErrorResponse + +type MergePersonsErrorResponseJSON428 = ErrorResponse + +type MergePersonsErrorResponseJSON500 = ErrorResponse + +type MergePersonsErrorResponseJSON503 = ErrorResponse + +type ListPersonMergesResponse = PersonMergesResponse + +type ListPersonMergesErrorResponse = ErrorResponse + +type ListPersonMergesErrorResponseJSON = ErrorResponse + +type ListPersonMergesErrorResponseJSON503 = ErrorResponse + type GetPersonStructuredProfileResponse = StructuredPersonProfile type GetPersonStructuredProfileErrorResponse = ErrorResponse @@ -1889,6 +1987,20 @@ type ListPersonRelationshipsErrorResponse = ErrorResponse type ListPersonRelationshipsErrorResponseJSON = ErrorResponse +type SplitPersonMergeResponse = PersonSplitResult + +type SplitPersonMergeErrorResponse = ErrorResponse + +type SplitPersonMergeErrorResponseJSON = ErrorResponse + +type SplitPersonMergeErrorResponseJSON409 = ErrorResponse + +type SplitPersonMergeErrorResponseJSON428 = ErrorResponse + +type SplitPersonMergeErrorResponseJSON500 = ErrorResponse + +type SplitPersonMergeErrorResponseJSON503 = ErrorResponse + type GetPersonTrackingResponse = PersonTracking type GetPersonTrackingErrorResponse = ErrorResponse @@ -1903,6 +2015,40 @@ type SetPersonTrackingErrorResponseJSON = ErrorResponse type SetPersonTrackingErrorResponseJSON503 = ErrorResponse +type DecidePersonMergeCandidateResponse = PersonMergeReviewCandidate + +type DecidePersonMergeCandidateErrorResponse = ErrorResponse + +type DecidePersonMergeCandidateErrorResponseJSON = ErrorResponse + +type DecidePersonMergeCandidateErrorResponseJSON409 = ErrorResponse + +type DecidePersonMergeCandidateErrorResponseJSON428 = ErrorResponse + +type DecidePersonMergeCandidateErrorResponseJSON500 = ErrorResponse + +type DecidePersonMergeCandidateErrorResponseJSON503 = ErrorResponse + +type GetPersonMergeResponse = PersonMergeDetail + +type GetPersonMergeErrorResponse = ErrorResponse + +type GetPersonMergeErrorResponseJSON = ErrorResponse + +type GetPersonMergeErrorResponseJSON500 = ErrorResponse + +type GetPersonMergeErrorResponseJSON503 = ErrorResponse + +type GetPersonMergeSnapshotResponse = PersonMergeSnapshotResponse + +type GetPersonMergeSnapshotErrorResponse = ErrorResponse + +type GetPersonMergeSnapshotErrorResponseJSON = ErrorResponse + +type GetPersonMergeSnapshotErrorResponseJSON500 = ErrorResponse + +type GetPersonMergeSnapshotErrorResponseJSON503 = ErrorResponse + type ListPersonRelationshipReviewsResponse = RelationshipReviewsResponse type ListPersonRelationshipReviewsErrorResponse = ErrorResponse @@ -3311,6 +3457,7 @@ type LinkIdentityParticipantsResp struct { Body []byte StatusCode int JSON200 *LinkIdentityParticipantsResponse + JSON409 *LinkIdentityParticipantsErrorResponse } type ListIdentityMatchCandidatesResp struct { @@ -3877,6 +4024,34 @@ type SearchPersonFilesResp struct { JSON503 *SearchPersonFilesErrorResponseJSON503 } +type MergePersonsResp200Headers struct { + ETag string `header:"ETag"` +} + +type MergePersonsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *MergePersonsResponse + Headers200 *MergePersonsResp200Headers + JSON400 *MergePersonsErrorResponse + JSON404 *MergePersonsErrorResponseJSON + JSON409 *MergePersonsErrorResponseJSON409 + JSON428 *MergePersonsErrorResponseJSON428 + JSON500 *MergePersonsErrorResponseJSON500 + JSON503 *MergePersonsErrorResponseJSON503 +} + +type ListPersonMergesResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListPersonMergesResponse + JSON404 *ListPersonMergesErrorResponse + JSON500 *ListPersonMergesErrorResponseJSON + JSON503 *ListPersonMergesErrorResponseJSON503 +} + type GetPersonStructuredProfileResp200Headers struct { ETag string `header:"ETag"` } @@ -3940,6 +4115,25 @@ type ListPersonRelationshipsResp struct { JSON503 *ListPersonRelationshipsErrorResponseJSON } +type SplitPersonMergeResp200Headers struct { + ETag string `header:"ETag"` + XNewPersonETag string `header:"X-New-Person-ETag"` +} + +type SplitPersonMergeResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *SplitPersonMergeResponse + Headers200 *SplitPersonMergeResp200Headers + JSON400 *SplitPersonMergeErrorResponse + JSON404 *SplitPersonMergeErrorResponseJSON + JSON409 *SplitPersonMergeErrorResponseJSON409 + JSON428 *SplitPersonMergeErrorResponseJSON428 + JSON500 *SplitPersonMergeErrorResponseJSON500 + JSON503 *SplitPersonMergeErrorResponseJSON503 +} + type GetPersonTrackingResp struct { HTTPResponse *http.Response Body []byte @@ -3959,6 +4153,51 @@ type SetPersonTrackingResp struct { JSON503 *SetPersonTrackingErrorResponseJSON503 } +type DecidePersonMergeCandidateResp200Headers struct { + ETag string `header:"ETag"` +} + +type DecidePersonMergeCandidateResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *DecidePersonMergeCandidateResponse + Headers200 *DecidePersonMergeCandidateResp200Headers + JSON400 *DecidePersonMergeCandidateErrorResponse + JSON404 *DecidePersonMergeCandidateErrorResponseJSON + JSON409 *DecidePersonMergeCandidateErrorResponseJSON409 + JSON428 *DecidePersonMergeCandidateErrorResponseJSON428 + JSON500 *DecidePersonMergeCandidateErrorResponseJSON500 + JSON503 *DecidePersonMergeCandidateErrorResponseJSON503 +} + +type GetPersonMergeResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetPersonMergeResponse + JSON400 *GetPersonMergeErrorResponse + JSON404 *GetPersonMergeErrorResponseJSON + JSON500 *GetPersonMergeErrorResponseJSON500 + JSON503 *GetPersonMergeErrorResponseJSON503 +} + +type GetPersonMergeSnapshotResp200Headers struct { + CacheControl string `header:"Cache-Control"` +} + +type GetPersonMergeSnapshotResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetPersonMergeSnapshotResponse + Headers200 *GetPersonMergeSnapshotResp200Headers + JSON400 *GetPersonMergeSnapshotErrorResponse + JSON404 *GetPersonMergeSnapshotErrorResponseJSON + JSON500 *GetPersonMergeSnapshotErrorResponseJSON500 + JSON503 *GetPersonMergeSnapshotErrorResponseJSON503 +} + type ListPersonRelationshipReviewsResp struct { HTTPResponse *http.Response Body []byte diff --git a/pkg/client/generated/types.go b/pkg/client/generated/types.go index 99c74cf1f..e0928fd72 100644 --- a/pkg/client/generated/types.go +++ b/pkg/client/generated/types.go @@ -1755,6 +1755,24 @@ type DecideIdentityMatchRequest struct { Notes *string `json:"notes,omitempty"` } +type DecidePersonMergeCandidateRequest struct { + Decision DecidePersonMergeCandidateRequestDecision `json:"decision" validate:"required"` + PersonID int64 `json:"person_id"` +} + +func (d DecidePersonMergeCandidateRequest) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(d.Decision).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Decision", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type DeepSearchResponse struct { BodyContexts []BodySearchContext `json:"body_contexts,omitempty"` Count int64 `json:"count"` @@ -4057,6 +4075,10 @@ type MergeOrganizationBody struct { LosingRevision int64 `json:"losing_revision"` } +type MergePersonRequest struct { + AbsorbedPersonID int64 `json:"absorbed_person_id"` +} + type MessageDetail struct { Attachments []AttachmentInfo `json:"attachments,omitempty" validate:"required"` Bcc []string `json:"bcc,omitempty"` @@ -5972,6 +5994,285 @@ func (p PersonMediaPatchRequest) Validate() error { return errors } +type PersonMerge struct { + AbsorbedPersonID int64 `json:"absorbed_person_id"` + AbsorbedRevisionBefore int64 `json:"absorbed_revision_before"` + AbsorbedVcardUID string `json:"absorbed_vcard_uid" validate:"required"` + Actor string `json:"actor" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + CurrentPersonID *int64 `json:"current_person_id,omitempty"` + ID int64 `json:"id"` + SnapshotSha256 string `json:"snapshot_sha256" validate:"required"` + SnapshotVersion int64 `json:"snapshot_version"` + SurvivorPersonID int64 `json:"survivor_person_id"` + SurvivorRevisionAfter int64 `json:"survivor_revision_after"` + SurvivorRevisionBefore int64 `json:"survivor_revision_before"` + SurvivorVcardUID string `json:"survivor_vcard_uid" validate:"required"` +} + +func (p PersonMerge) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PersonMergeDetail struct { + Merge PersonMerge `json:"merge"` + Participants []PersonMergeParticipant `json:"participants" validate:"required"` + ReviewCandidates []PersonMergeReviewCandidate `json:"review_candidates" validate:"required"` + Rows []PersonMergeRow `json:"rows" validate:"required"` + Splits []PersonSplit `json:"splits" validate:"required"` +} + +func (p PersonMergeDetail) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(p.Merge).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Merge", err) + } + } + for i, item := range p.Participants { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Participants[%d]", i), err) + } + } + } + for i, item := range p.ReviewCandidates { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("ReviewCandidates[%d]", i), err) + } + } + } + for i, item := range p.Rows { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Rows[%d]", i), err) + } + } + } + for i, item := range p.Splits { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Splits[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonMergeParticipant struct { + MergeID int64 `json:"merge_id"` + OriginSide string `json:"origin_side" validate:"required"` + ParticipantID int64 `json:"participant_id"` + SplitID *int64 `json:"split_id,omitempty"` +} + +func (p PersonMergeParticipant) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PersonMergeProfile struct { + Etag string `json:"etag" validate:"required"` + Person Person `json:"person"` +} + +func (p PersonMergeProfile) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.Etag, "required"); err != nil { + errors = errors.Append("Etag", err) + } + if v, ok := any(p.Person).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Person", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonMergeRequiredError struct { + ErrorData string `json:"error" validate:"required"` + Message string `json:"message" validate:"required"` + Profiles []PersonMergeProfile `json:"profiles,omitempty" validate:"required"` +} + +func (p PersonMergeRequiredError) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.ErrorData, "required"); err != nil { + errors = errors.Append("ErrorData", err) + } + if err := typesValidator.Var(p.Message, "required"); err != nil { + errors = errors.Append("Message", err) + } + for i, item := range p.Profiles { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Profiles[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonMergeResult struct { + CacheState PersonMergeResultCacheState `json:"cache_state" validate:"required"` + IdentityRevision int64 `json:"identity_revision"` + Merge PersonMerge `json:"merge"` + Person Person `json:"person"` + ReviewCandidates []PersonMergeReviewCandidate `json:"review_candidates" validate:"required"` +} + +func (p PersonMergeResult) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(p.CacheState).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("CacheState", err) + } + } + if v, ok := any(p.Merge).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Merge", err) + } + } + if v, ok := any(p.Person).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Person", err) + } + } + for i, item := range p.ReviewCandidates { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("ReviewCandidates[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonMergeReviewCandidate struct { + AbsorbedValueID int64 `json:"absorbed_value_id"` + CreatedAt time.Time `json:"created_at" validate:"required"` + DefinitionID int64 `json:"definition_id"` + ID int64 `json:"id"` + MergeID int64 `json:"merge_id"` + PersonID int64 `json:"person_id"` + ResolutionValueID *int64 `json:"resolution_value_id,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + ReviewedBy *string `json:"reviewed_by,omitempty"` + State string `json:"state" validate:"required"` + SurvivorValueID int64 `json:"survivor_value_id"` +} + +func (p PersonMergeReviewCandidate) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PersonMergeRow struct { + Action string `json:"action" validate:"required"` + CurrentRowID *int64 `json:"current_row_id,omitempty"` + CurrentRowKey *string `json:"current_row_key,omitempty"` + MergeID int64 `json:"merge_id"` + OriginSide string `json:"origin_side" validate:"required"` + OriginalRowID *int64 `json:"original_row_id,omitempty"` + OriginalRowKey string `json:"original_row_key" validate:"required"` + ParticipantID *int64 `json:"participant_id,omitempty"` + ProvenanceKind string `json:"provenance_kind" validate:"required"` + SnapshotPath string `json:"snapshot_path" validate:"required"` + SplitID *int64 `json:"split_id,omitempty"` + TableName string `json:"table_name" validate:"required"` +} + +func (p PersonMergeRow) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PersonMergeRowRef struct { + Action string `json:"action" validate:"required"` + OriginalRowID *int64 `json:"original_row_id,omitempty"` + OriginalRowKey string `json:"original_row_key" validate:"required"` + TableName string `json:"table_name" validate:"required"` +} + +func (p PersonMergeRowRef) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PersonMergeSnapshotResponse struct { + Sha256 string `json:"sha256" validate:"required"` + Snapshot json.RawMessage `json:"snapshot"` + Version int64 `json:"version"` +} + +func (p PersonMergeSnapshotResponse) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.Sha256, "required"); err != nil { + errors = errors.Append("Sha256", err) + } + if v, ok := any(p.Snapshot).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Snapshot", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonMergeSummary struct { + Merge PersonMerge `json:"merge"` + ParticipantCount int64 `json:"participant_count"` + PendingCandidateCount int64 `json:"pending_candidate_count"` + RowActionCounts map[string]int64 `json:"row_action_counts"` + RowCount int64 `json:"row_count"` + SplitCount int64 `json:"split_count"` +} + +func (p PersonMergeSummary) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(p.Merge).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Merge", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonMergesResponse struct { + Limit int64 `json:"limit"` + Merges []PersonMergeSummary `json:"merges,omitempty" validate:"required"` + Offset int64 `json:"offset"` +} + +func (p PersonMergesResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Merges { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Merges[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type PersonName struct { AdditionalNames *string `json:"additional_names,omitempty"` Envelope ValueEnvelope `json:"envelope"` @@ -6381,6 +6682,80 @@ func (p PersonSearchResult) Validate() error { return errors } +type PersonSplit struct { + Actor string `json:"actor" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + ExactReversal bool `json:"exact_reversal"` + ID int64 `json:"id"` + MergeID int64 `json:"merge_id"` + NewPersonID int64 `json:"new_person_id"` + NewPersonUID string `json:"new_person_uid" validate:"required"` + SourcePersonID int64 `json:"source_person_id"` + SourceRevisionAfter int64 `json:"source_revision_after"` + SourceRevisionBefore int64 `json:"source_revision_before"` +} + +func (p PersonSplit) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PersonSplitResult struct { + AmbiguousRows []PersonMergeRowRef `json:"ambiguous_rows" validate:"required"` + CacheState PersonSplitResultCacheState `json:"cache_state" validate:"required"` + ExactReversal bool `json:"exact_reversal"` + IdentityRevision int64 `json:"identity_revision"` + NewPerson Person `json:"new_person"` + SourcePerson Person `json:"source_person"` + Split PersonSplit `json:"split"` + UIDAliasDisposition string `json:"uid_alias_disposition" validate:"required"` + UnrestoredRows []PersonMergeRowRef `json:"unrestored_rows,omitempty" validate:"required"` +} + +func (p PersonSplitResult) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.AmbiguousRows { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("AmbiguousRows[%d]", i), err) + } + } + } + if v, ok := any(p.CacheState).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("CacheState", err) + } + } + if v, ok := any(p.NewPerson).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("NewPerson", err) + } + } + if v, ok := any(p.SourcePerson).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("SourcePerson", err) + } + } + if v, ok := any(p.Split).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Split", err) + } + } + if err := typesValidator.Var(p.UIDAliasDisposition, "required"); err != nil { + errors = errors.Append("UIDAliasDisposition", err) + } + for i, item := range p.UnrestoredRows { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("UnrestoredRows[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type PersonSummary struct { ActivityCount int64 `json:"activity_count"` CacheRevision string `json:"cache_revision" validate:"required"` @@ -7645,6 +8020,15 @@ func (s SourcesRequest) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(s)) } +type SplitPersonRequest struct { + MergeID int64 `json:"merge_id"` + ParticipantIds []int64 `json:"participant_ids,omitempty" validate:"required"` +} + +func (s SplitPersonRequest) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(s)) +} + type StageDeletionFilter struct { After *string `json:"after,omitempty"` Before *string `json:"before,omitempty"` diff --git a/pkg/client/generated/unions.go b/pkg/client/generated/unions.go index 16e5a0110..490efb157 100644 --- a/pkg/client/generated/unions.go +++ b/pkg/client/generated/unions.go @@ -470,6 +470,42 @@ func (s *SearchFiles_ErrorResponse_503_AnyOf) Validate() error { return nil } +type LinkIdentityParticipants_ErrorResponse_AnyOf struct { + runtime.Either[PersonMergeRequiredError, ErrorResponse] +} + +func (l *LinkIdentityParticipants_ErrorResponse_AnyOf) Validate() error { + if l.IsA() { + if v, ok := any(l.A).(runtime.Validator); ok { + return v.Validate() + } + } + if l.IsB() { + if v, ok := any(l.B).(runtime.Validator); ok { + return v.Validate() + } + } + return nil +} + +type AcceptIdentityMatchCandidate_ErrorResponse_409_AnyOf struct { + runtime.Either[PersonMergeRequiredError, ErrorResponse] +} + +func (a *AcceptIdentityMatchCandidate_ErrorResponse_409_AnyOf) Validate() error { + if a.IsA() { + if v, ok := any(a.A).(runtime.Validator); ok { + return v.Validate() + } + } + if a.IsB() { + if v, ok := any(a.B).(runtime.Validator); ok { + return v.Validate() + } + } + return nil +} + type SearchParticipants_ErrorResponse_503_AnyOf struct { runtime.Either[ExploreCacheUnavailableResponse, ErrorResponse] } diff --git a/pkg/client/openapi.yaml b/pkg/client/openapi.yaml index 4e9d252bc..036c2d853 100644 --- a/pkg/client/openapi.yaml +++ b/pkg/client/openapi.yaml @@ -2081,6 +2081,21 @@ components: notes: type: string type: object + DecidePersonMergeCandidateRequest: + additionalProperties: false + properties: + decision: + enum: + - accept + - reject + type: string + person_id: + format: int64 + type: integer + required: + - person_id + - decision + type: object DeepSearchResponse: properties: body_contexts: @@ -4471,6 +4486,15 @@ components: - losing_organization_id - losing_revision type: object + MergePersonRequest: + additionalProperties: false + properties: + absorbed_person_id: + format: int64 + type: integer + required: + - absorbed_person_id + type: object MessageDetail: properties: attachments: @@ -6508,6 +6532,325 @@ components: nullable: true type: array type: object + PersonMerge: + properties: + absorbed_person_id: + format: int64 + type: integer + absorbed_revision_before: + format: int64 + type: integer + absorbed_vcard_uid: + type: string + actor: + type: string + created_at: + format: date-time + type: string + current_person_id: + format: int64 + type: integer + id: + format: int64 + type: integer + snapshot_sha256: + type: string + snapshot_version: + format: int64 + type: integer + survivor_person_id: + format: int64 + type: integer + survivor_revision_after: + format: int64 + type: integer + survivor_revision_before: + format: int64 + type: integer + survivor_vcard_uid: + type: string + required: + - id + - survivor_person_id + - absorbed_person_id + - survivor_vcard_uid + - absorbed_vcard_uid + - survivor_revision_before + - absorbed_revision_before + - survivor_revision_after + - actor + - snapshot_version + - snapshot_sha256 + - created_at + type: object + PersonMergeDetail: + properties: + merge: + $ref: "#/components/schemas/PersonMerge" + participants: + items: + $ref: "#/components/schemas/PersonMergeParticipant" + nullable: true + type: array + x-omitempty: false + review_candidates: + items: + $ref: "#/components/schemas/PersonMergeReviewCandidate" + nullable: true + type: array + x-omitempty: false + rows: + items: + $ref: "#/components/schemas/PersonMergeRow" + nullable: true + type: array + x-omitempty: false + splits: + items: + $ref: "#/components/schemas/PersonSplit" + nullable: true + type: array + x-omitempty: false + required: + - merge + - participants + - rows + - splits + - review_candidates + type: object + PersonMergeParticipant: + properties: + merge_id: + format: int64 + type: integer + origin_side: + type: string + participant_id: + format: int64 + type: integer + split_id: + format: int64 + type: integer + required: + - merge_id + - participant_id + - origin_side + type: object + PersonMergeProfile: + properties: + etag: + type: string + person: + $ref: "#/components/schemas/Person" + required: + - person + - etag + type: object + PersonMergeRequiredError: + properties: + error: + type: string + message: + type: string + profiles: + items: + $ref: "#/components/schemas/PersonMergeProfile" + nullable: true + type: array + required: + - error + - message + - profiles + type: object + PersonMergeResult: + properties: + cache_state: + enum: + - ready + - stale + type: string + identity_revision: + format: int64 + type: integer + merge: + $ref: "#/components/schemas/PersonMerge" + person: + $ref: "#/components/schemas/Person" + review_candidates: + items: + $ref: "#/components/schemas/PersonMergeReviewCandidate" + nullable: true + type: array + x-omitempty: false + required: + - person + - merge + - review_candidates + - identity_revision + - cache_state + type: object + PersonMergeReviewCandidate: + properties: + absorbed_value_id: + format: int64 + type: integer + created_at: + format: date-time + type: string + definition_id: + format: int64 + type: integer + id: + format: int64 + type: integer + merge_id: + format: int64 + type: integer + person_id: + format: int64 + type: integer + resolution_value_id: + format: int64 + type: integer + reviewed_at: + format: date-time + type: string + reviewed_by: + type: string + state: + type: string + survivor_value_id: + format: int64 + type: integer + required: + - id + - merge_id + - person_id + - definition_id + - survivor_value_id + - absorbed_value_id + - state + - created_at + type: object + PersonMergeRow: + properties: + action: + type: string + current_row_id: + format: int64 + type: integer + current_row_key: + type: string + merge_id: + format: int64 + type: integer + origin_side: + type: string + original_row_id: + format: int64 + type: integer + original_row_key: + type: string + participant_id: + format: int64 + type: integer + provenance_kind: + type: string + snapshot_path: + type: string + split_id: + format: int64 + type: integer + table_name: + type: string + required: + - merge_id + - table_name + - original_row_key + - origin_side + - provenance_kind + - action + - snapshot_path + type: object + PersonMergeRowRef: + properties: + action: + type: string + original_row_id: + format: int64 + type: integer + original_row_key: + type: string + table_name: + type: string + required: + - table_name + - original_row_key + - action + type: object + PersonMergeSnapshotResponse: + properties: + sha256: + type: string + snapshot: + x-go-type: json.RawMessage + x-go-type-import: + path: encoding/json + version: + format: int64 + type: integer + required: + - version + - sha256 + - snapshot + type: object + PersonMergeSummary: + properties: + merge: + $ref: "#/components/schemas/PersonMerge" + participant_count: + format: int64 + type: integer + pending_candidate_count: + format: int64 + type: integer + row_action_counts: + additionalProperties: + format: int64 + type: integer + type: object + row_count: + format: int64 + type: integer + split_count: + format: int64 + type: integer + required: + - merge + - participant_count + - row_count + - split_count + - pending_candidate_count + - row_action_counts + type: object + PersonMergesResponse: + properties: + limit: + format: int64 + type: integer + merges: + items: + $ref: "#/components/schemas/PersonMergeSummary" + nullable: true + type: array + offset: + format: int64 + type: integer + required: + - merges + - limit + - offset + type: object PersonName: properties: additional_names: @@ -6828,35 +7171,118 @@ components: - person - score type: object - PersonSummary: + PersonSplit: properties: - activity_count: - format: int64 - type: integer - cache_revision: - type: string - cluster: - $ref: "#/components/schemas/PersonCluster" - display_label: - type: string - display_name: + actor: type: string - file_count: - format: int64 - type: integer - first_at: + created_at: format: date-time type: string + exact_reversal: + type: boolean id: format: int64 type: integer - identifiers: - items: - $ref: "#/components/schemas/PersonIdentifier" - nullable: true - type: array - last_at: - format: date-time + merge_id: + format: int64 + type: integer + new_person_id: + format: int64 + type: integer + new_person_uid: + type: string + source_person_id: + format: int64 + type: integer + source_revision_after: + format: int64 + type: integer + source_revision_before: + format: int64 + type: integer + required: + - id + - merge_id + - source_person_id + - new_person_id + - new_person_uid + - source_revision_before + - source_revision_after + - actor + - exact_reversal + - created_at + type: object + PersonSplitResult: + properties: + ambiguous_rows: + items: + $ref: "#/components/schemas/PersonMergeRowRef" + nullable: true + type: array + x-omitempty: false + cache_state: + enum: + - ready + - stale + type: string + exact_reversal: + type: boolean + identity_revision: + format: int64 + type: integer + new_person: + $ref: "#/components/schemas/Person" + source_person: + $ref: "#/components/schemas/Person" + split: + $ref: "#/components/schemas/PersonSplit" + uid_alias_disposition: + type: string + unrestored_rows: + items: + $ref: "#/components/schemas/PersonMergeRowRef" + nullable: true + type: array + required: + - split + - source_person + - new_person + - exact_reversal + - uid_alias_disposition + - ambiguous_rows + - unrestored_rows + - identity_revision + - cache_state + type: object + PersonSummary: + properties: + activity_count: + format: int64 + type: integer + cache_revision: + type: string + cluster: + $ref: "#/components/schemas/PersonCluster" + display_label: + type: string + display_name: + type: string + file_count: + format: int64 + type: integer + first_at: + format: date-time + type: string + id: + format: int64 + type: integer + identifiers: + items: + $ref: "#/components/schemas/PersonIdentifier" + nullable: true + type: array + last_at: + format: date-time type: string partial_label: type: boolean @@ -8053,6 +8479,22 @@ components: required: - accounts type: object + SplitPersonRequest: + additionalProperties: false + properties: + merge_id: + format: int64 + type: integer + participant_ids: + items: + format: int64 + type: integer + nullable: true + type: array + required: + - merge_id + - participant_ids + type: object StageDeletionFilter: additionalProperties: false properties: @@ -9064,7 +9506,7 @@ components: type: apiKey info: title: msgvault API - version: 2.8.0 + version: 2.9.0 openapi: 3.0.3 paths: /api/ping: @@ -13819,6 +14261,14 @@ paths: schema: $ref: "#/components/schemas/IdentityLinkResponse" description: OK + "409": + content: + application/json: + schema: + anyOf: + - $ref: "#/components/schemas/PersonMergeRequiredError" + - $ref: "#/components/schemas/ErrorResponse" + description: Conflict default: content: application/json: @@ -13910,8 +14360,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error + anyOf: + - $ref: "#/components/schemas/PersonMergeRequiredError" + - $ref: "#/components/schemas/ErrorResponse" + description: Conflict "503": content: application/json: @@ -16780,6 +17232,155 @@ paths: summary: Search one durable person's analytical files tags: - Exploration + /api/v1/people/{id}/merge: + post: + operationId: mergePersons + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Exactly two comma-separated strong person revision tags, one for each profile + in: header + name: If-Match + required: true + schema: + type: string + - description: Opaque 1..128-byte retry key + in: header + name: Idempotency-Key + required: true + schema: + maxLength: 128 + minLength: 1 + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MergePersonRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergeResult" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Merge one durable person profile into another + tags: + - API + /api/v1/people/{id}/merges: + get: + operationId: listPersonMerges + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Maximum results + in: query + name: limit + schema: + format: int64 + type: integer + - description: Results to skip + in: query + name: offset + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergesResponse" + description: OK + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List merge history for a durable person + tags: + - API /api/v1/people/{id}/profile: get: description: Returns only current structured values at one person revision. Superseded values and archive observations are available from the separate history endpoint. @@ -17077,6 +17678,100 @@ paths: summary: List one person's relationships tags: - API + /api/v1/people/{id}/split: + post: + operationId: splitPersonMerge + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. + in: header + name: If-Match + required: true + schema: + type: string + - description: Opaque 1..128-byte retry key + in: header + name: Idempotency-Key + required: true + schema: + maxLength: 128 + minLength: 1 + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SplitPersonRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonSplitResult" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + X-New-Person-ETag: + description: Strong revision tag for the new person created by a split + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Split absorbed participant lineage into a new person + tags: + - API /api/v1/people/{id}/tracking: get: operationId: getPersonTracking @@ -17170,6 +17865,202 @@ paths: summary: Replace a person's tracking state tags: - API + /api/v1/person-merge-candidates/{candidate_id}/decision: + post: + operationId: decidePersonMergeCandidate + parameters: + - description: Person merge review candidate ID + in: path + name: candidate_id + required: true + schema: + format: int64 + minimum: 1 + type: integer + - description: Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DecidePersonMergeCandidateRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergeReviewCandidate" + description: OK + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Accept or reject a person merge attribute candidate + tags: + - API + /api/v1/person-merges/{merge_id}: + get: + operationId: getPersonMerge + parameters: + - description: Durable person merge ID + in: path + name: merge_id + required: true + schema: + format: int64 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergeDetail" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Inspect one durable person merge + tags: + - API + /api/v1/person-merges/{merge_id}/snapshot: + get: + operationId: getPersonMergeSnapshot + parameters: + - description: Durable person merge ID + in: path + name: merge_id + required: true + schema: + format: int64 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonMergeSnapshotResponse" + description: OK + headers: + Cache-Control: + description: Always no-store because the response contains merge provenance + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Read and verify one person merge snapshot + tags: + - API /api/v1/person-relationship-reviews: get: operationId: listPersonRelationshipReviews diff --git a/web/src/lib/api/generated/schema.d.ts b/web/src/lib/api/generated/schema.d.ts index b3349dcaa..b75de8402 100644 --- a/web/src/lib/api/generated/schema.d.ts +++ b/web/src/lib/api/generated/schema.d.ts @@ -2222,6 +2222,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/people/{id}/merge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Merge one durable person profile into another */ + post: operations["mergePersons"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/people/{id}/merges": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List merge history for a durable person */ + get: operations["listPersonMerges"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/people/{id}/profile": { parameters: { query?: never; @@ -2303,6 +2337,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/people/{id}/split": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Split absorbed participant lineage into a new person */ + post: operations["splitPersonMerge"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/people/{id}/tracking": { parameters: { query?: never; @@ -2321,6 +2372,57 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/person-merge-candidates/{candidate_id}/decision": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Accept or reject a person merge attribute candidate */ + post: operations["decidePersonMergeCandidate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/person-merges/{merge_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Inspect one durable person merge */ + get: operations["getPersonMerge"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/person-merges/{merge_id}/snapshot": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read and verify one person merge snapshot */ + get: operations["getPersonMergeSnapshot"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/person-relationship-reviews": { parameters: { query?: never; @@ -3845,6 +3947,12 @@ export interface components { DecideIdentityMatchRequest: { notes?: string; }; + DecidePersonMergeCandidateRequest: { + /** @enum {string} */ + decision: "accept" | "reject"; + /** Format: int64 */ + person_id: number; + }; DeepSearchResponse: { body_contexts?: components["schemas"]["BodySearchContext"][] | null; /** Format: int64 */ @@ -4894,6 +5002,10 @@ export interface components { /** Format: int64 */ losing_revision: number; }; + MergePersonRequest: { + /** Format: int64 */ + absorbed_person_id: number; + }; MessageDetail: { attachments: components["schemas"]["AttachmentInfo"][] | null; bcc?: string[] | null; @@ -5737,6 +5849,163 @@ export interface components { add?: components["schemas"]["PersonMediaInputRequest"][] | null; supersede?: number[] | null; }; + PersonMerge: { + /** Format: int64 */ + absorbed_person_id: number; + /** Format: int64 */ + absorbed_revision_before: number; + absorbed_vcard_uid: string; + actor: string; + /** Format: date-time */ + created_at: string; + /** Format: int64 */ + current_person_id?: number; + /** Format: int64 */ + id: number; + snapshot_sha256: string; + /** Format: int64 */ + snapshot_version: number; + /** Format: int64 */ + survivor_person_id: number; + /** Format: int64 */ + survivor_revision_after: number; + /** Format: int64 */ + survivor_revision_before: number; + survivor_vcard_uid: string; + } & { + [key: string]: unknown; + }; + PersonMergeDetail: { + merge: components["schemas"]["PersonMerge"]; + participants: components["schemas"]["PersonMergeParticipant"][] | null; + review_candidates: components["schemas"]["PersonMergeReviewCandidate"][] | null; + rows: components["schemas"]["PersonMergeRow"][] | null; + splits: components["schemas"]["PersonSplit"][] | null; + } & { + [key: string]: unknown; + }; + PersonMergeParticipant: { + /** Format: int64 */ + merge_id: number; + origin_side: string; + /** Format: int64 */ + participant_id: number; + /** Format: int64 */ + split_id?: number; + } & { + [key: string]: unknown; + }; + PersonMergeProfile: { + etag: string; + person: components["schemas"]["Person"]; + } & { + [key: string]: unknown; + }; + PersonMergeRequiredError: { + error: string; + message: string; + profiles: components["schemas"]["PersonMergeProfile"][] | null; + } & { + [key: string]: unknown; + }; + PersonMergeResult: { + /** @enum {string} */ + cache_state: "ready" | "stale"; + /** Format: int64 */ + identity_revision: number; + merge: components["schemas"]["PersonMerge"]; + person: components["schemas"]["Person"]; + review_candidates: components["schemas"]["PersonMergeReviewCandidate"][] | null; + } & { + [key: string]: unknown; + }; + PersonMergeReviewCandidate: { + /** Format: int64 */ + absorbed_value_id: number; + /** Format: date-time */ + created_at: string; + /** Format: int64 */ + definition_id: number; + /** Format: int64 */ + id: number; + /** Format: int64 */ + merge_id: number; + /** Format: int64 */ + person_id: number; + /** Format: int64 */ + resolution_value_id?: number; + /** Format: date-time */ + reviewed_at?: string; + reviewed_by?: string; + state: string; + /** Format: int64 */ + survivor_value_id: number; + } & { + [key: string]: unknown; + }; + PersonMergeRow: { + action: string; + /** Format: int64 */ + current_row_id?: number; + current_row_key?: string; + /** Format: int64 */ + merge_id: number; + origin_side: string; + /** Format: int64 */ + original_row_id?: number; + original_row_key: string; + /** Format: int64 */ + participant_id?: number; + provenance_kind: string; + snapshot_path: string; + /** Format: int64 */ + split_id?: number; + table_name: string; + } & { + [key: string]: unknown; + }; + PersonMergeRowRef: { + action: string; + /** Format: int64 */ + original_row_id?: number; + original_row_key: string; + table_name: string; + } & { + [key: string]: unknown; + }; + PersonMergeSnapshotResponse: { + sha256: string; + snapshot: unknown; + /** Format: int64 */ + version: number; + } & { + [key: string]: unknown; + }; + PersonMergeSummary: { + merge: components["schemas"]["PersonMerge"]; + /** Format: int64 */ + participant_count: number; + /** Format: int64 */ + pending_candidate_count: number; + row_action_counts: { + [key: string]: number; + }; + /** Format: int64 */ + row_count: number; + /** Format: int64 */ + split_count: number; + } & { + [key: string]: unknown; + }; + PersonMergesResponse: { + /** Format: int64 */ + limit: number; + merges: components["schemas"]["PersonMergeSummary"][] | null; + /** Format: int64 */ + offset: number; + } & { + [key: string]: unknown; + }; PersonName: { additional_names?: string; envelope: components["schemas"]["ValueEnvelope"]; @@ -5882,6 +6151,42 @@ export interface components { } & { [key: string]: unknown; }; + PersonSplit: { + actor: string; + /** Format: date-time */ + created_at: string; + exact_reversal: boolean; + /** Format: int64 */ + id: number; + /** Format: int64 */ + merge_id: number; + /** Format: int64 */ + new_person_id: number; + new_person_uid: string; + /** Format: int64 */ + source_person_id: number; + /** Format: int64 */ + source_revision_after: number; + /** Format: int64 */ + source_revision_before: number; + } & { + [key: string]: unknown; + }; + PersonSplitResult: { + ambiguous_rows: components["schemas"]["PersonMergeRowRef"][] | null; + /** @enum {string} */ + cache_state: "ready" | "stale"; + exact_reversal: boolean; + /** Format: int64 */ + identity_revision: number; + new_person: components["schemas"]["Person"]; + source_person: components["schemas"]["Person"]; + split: components["schemas"]["PersonSplit"]; + uid_alias_disposition: string; + unrestored_rows: components["schemas"]["PersonMergeRowRef"][] | null; + } & { + [key: string]: unknown; + }; PersonSummary: { /** Format: int64 */ activity_count: number; @@ -6421,6 +6726,11 @@ export interface components { SourcesRequest: { accounts: string[] | null; }; + SplitPersonRequest: { + /** Format: int64 */ + merge_id: number; + participant_ids: number[] | null; + }; StageDeletionFilter: { after?: string; before?: string; @@ -12420,6 +12730,15 @@ export interface operations { "application/json": components["schemas"]["IdentityLinkResponse"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonMergeRequiredError"] | components["schemas"]["ErrorResponse"]; + }; + }; /** @description Error */ default: { headers: { @@ -12510,13 +12829,13 @@ export interface operations { "application/json": components["schemas"]["ErrorResponse"]; }; }; - /** @description Error */ + /** @description Conflict */ 409: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ErrorResponse"]; + "application/json": components["schemas"]["PersonMergeRequiredError"] | components["schemas"]["ErrorResponse"]; }; }; /** @description Error */ @@ -15664,17 +15983,26 @@ export interface operations { }; }; }; - getPersonStructuredProfile: { + mergePersons: { parameters: { query?: never; - header?: never; + header: { + /** @description Exactly two comma-separated strong person revision tags, one for each profile */ + "If-Match": string; + /** @description Opaque 1..128-byte retry key */ + "Idempotency-Key": string; + }; path: { /** @description Durable person ID */ id: number; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["MergePersonRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -15684,7 +16012,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["StructuredPersonProfile"]; + "application/json": components["schemas"]["PersonMergeResult"]; }; }; /** @description Error */ @@ -15706,7 +16034,7 @@ export interface operations { }; }; /** @description Error */ - 503: { + 409: { headers: { [name: string]: unknown; }; @@ -15715,7 +16043,7 @@ export interface operations { }; }; /** @description Error */ - default: { + 428: { headers: { [name: string]: unknown; }; @@ -15723,18 +16051,170 @@ export interface operations { "application/json": components["schemas"]["ErrorResponse"]; }; }; - }; - }; - patchPersonStructuredProfile: { - parameters: { - query?: never; - header: { - /** @description Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. */ - "If-Match": string; - }; - path: { - /** @description Durable person ID */ - id: number; + /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + listPersonMerges: { + parameters: { + query?: { + /** @description Maximum results */ + limit?: number; + /** @description Results to skip */ + offset?: number; + }; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonMergesResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getPersonStructuredProfile: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Strong person profile revision tag for optimistic concurrency */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StructuredPersonProfile"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + patchPersonStructuredProfile: { + parameters: { + query?: never; + header: { + /** @description Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. */ + "If-Match": string; + }; + path: { + /** @description Durable person ID */ + id: number; }; cookie?: never; }; @@ -16010,6 +16490,105 @@ export interface operations { }; }; }; + splitPersonMerge: { + parameters: { + query?: never; + header: { + /** @description Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. */ + "If-Match": string; + /** @description Opaque 1..128-byte retry key */ + "Idempotency-Key": string; + }; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SplitPersonRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Strong person profile revision tag for optimistic concurrency */ + ETag?: string; + /** @description Strong revision tag for the new person created by a split */ + "X-New-Person-ETag"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonSplitResult"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; getPersonTracking: { parameters: { query?: never; @@ -16123,6 +16702,239 @@ export interface operations { }; }; }; + decidePersonMergeCandidate: { + parameters: { + query?: never; + header: { + /** @description Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. */ + "If-Match": string; + }; + path: { + /** @description Person merge review candidate ID */ + candidate_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DecidePersonMergeCandidateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Strong person profile revision tag for optimistic concurrency */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonMergeReviewCandidate"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getPersonMerge: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Durable person merge ID */ + merge_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonMergeDetail"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getPersonMergeSnapshot: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Durable person merge ID */ + merge_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description Always no-store because the response contains merge provenance */ + "Cache-Control"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonMergeSnapshotResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; listPersonRelationshipReviews: { parameters: { query?: {