From 4388d03e7782322a73f2eee9b3599638d28ee98f Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:18:27 -0500 Subject: [PATCH 1/4] feat(people): add reversible profile merges Curated profiles need a safe way to consolidate duplicate people without losing their original lineage. Add atomic merge and split records that support exact and partial restoration across SQLite and PostgreSQL, and preserve the history in complete subset exports. Follow-up fixes included in this squash: - fix(people): close fully split merge lineage - fix(people): close chained split lineage - fix(people): prune incomplete split packet chains - fix(people): preserve chained split profile data - chore(api): refresh web conflict schema - chore(store): share friend relationship literal - fix(people): restore split and conflict state - fix(people): preserve decisions and closed merge history - chore(people): integrate CardDAV merge boundaries Generated with Codex Co-authored-by: Wes McKinney Co-authored-by: Codex --- api/openapi.yaml | 920 +++++- cmd/msgvault/cmd/create_subset.go | 13 +- cmd/msgvault/cmd/create_subset_test.go | 2 + cmd/msgvault/cmd/person.go | 427 ++- cmd/msgvault/cmd/person_test.go | 236 ++ cmd/msgvault/cmd/serve.go | 36 + internal/activity/classify.go | 318 -- internal/activity/classify_test.go | 12 +- internal/activity/projector.go | 13 +- internal/api/activity_routes.go | 2 +- internal/api/attribute_definitions.go | 4 +- internal/api/employments.go | 2 +- internal/api/identity_links.go | 13 +- internal/api/identity_links_test.go | 75 + internal/api/identity_match_candidates.go | 9 +- .../api/identity_match_candidates_test.go | 22 +- internal/api/middleware.go | 20 +- internal/api/middleware_test.go | 6 +- internal/api/openapi.go | 32 +- internal/api/openapi_test.go | 34 +- internal/api/organizations.go | 4 +- internal/api/person_attributes.go | 2 +- internal/api/person_merges.go | 563 ++++ internal/api/person_merges_test.go | 462 +++ internal/api/person_profiles.go | 23 +- internal/api/person_relationships.go | 4 +- internal/api/routes.go | 9 +- internal/api/saved_views.go | 4 +- internal/api/server.go | 8 +- internal/api/settings.go | 2 +- internal/store/activity.go | 182 ++ internal/store/activity_classify.go | 264 ++ internal/store/activity_columns.go | 2 +- internal/store/activity_queries.go | 64 +- internal/store/activity_queries_test.go | 5 +- internal/store/activity_test.go | 7 +- internal/store/attribute_definitions.go | 2 +- internal/store/backup_test.go | 50 + internal/store/contended_write.go | 29 + internal/store/content_columns.go | 2 +- internal/store/dialect_pg.go | 8 +- internal/store/dialect_sqlite.go | 4 +- internal/store/export_test.go | 23 + internal/store/identity_match_apply.go | 55 +- internal/store/identity_match_apply_test.go | 114 + internal/store/identity_match_candidates.go | 83 +- internal/store/messages.go | 5 + internal/store/migrate_phone_unique.go | 3 + ...ate_vcard_source_resource_identity_test.go | 35 + internal/store/organization_attributes.go | 5 + internal/store/participant_links.go | 202 +- internal/store/person_attributes.go | 7 +- internal/store/person_merge_inspection.go | 513 ++++ internal/store/person_merge_snapshot.go | 980 ++++++ internal/store/person_merge_snapshot_test.go | 254 ++ .../person_merge_validation_internal_test.go | 82 + internal/store/person_merges.go | 2231 ++++++++++++++ internal/store/person_merges_test.go | 2456 +++++++++++++++ internal/store/person_splits.go | 2298 ++++++++++++++ internal/store/person_splits_test.go | 2735 +++++++++++++++++ internal/store/persons.go | 40 + internal/store/postgres_integration_test.go | 128 + internal/store/relationship_type_seed.go | 5 +- internal/store/relationship_types.go | 4 +- internal/store/schema.sql | 133 + internal/store/schema_pg.sql | 133 + internal/store/store.go | 23 +- internal/store/subset.go | 819 ++++- internal/store/subset_test.go | 1022 +++++- internal/store/sync.go | 2 +- .../store/vcard_source_resource_rewrite.go | 8 +- pkg/client/client_test.go | 106 + pkg/client/generated/client.go | 405 +++ pkg/client/generated/client_options.go | 327 ++ pkg/client/generated/client_with_response.go | 828 ++++- pkg/client/generated/enums.go | 51 + pkg/client/generated/headers.go | 33 + pkg/client/generated/paths.go | 42 + pkg/client/generated/payloads.go | 6 + pkg/client/generated/queries.go | 8 + pkg/client/generated/responses.go | 243 +- pkg/client/generated/types.go | 384 +++ pkg/client/generated/unions.go | 36 + pkg/client/openapi.yaml | 941 +++++- web/src/lib/api/generated/schema.d.ts | 852 ++++- 85 files changed, 21982 insertions(+), 574 deletions(-) delete mode 100644 internal/activity/classify.go create mode 100644 internal/api/person_merges.go create mode 100644 internal/api/person_merges_test.go create mode 100644 internal/store/activity_classify.go create mode 100644 internal/store/migrate_vcard_source_resource_identity_test.go create mode 100644 internal/store/person_merge_inspection.go create mode 100644 internal/store/person_merge_snapshot.go create mode 100644 internal/store/person_merge_snapshot_test.go create mode 100644 internal/store/person_merge_validation_internal_test.go create mode 100644 internal/store/person_merges.go create mode 100644 internal/store/person_merges_test.go create mode 100644 internal/store/person_splits.go create mode 100644 internal/store/person_splits_test.go create mode 100644 internal/store/postgres_integration_test.go 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..8ab0f88b6 100644 --- a/cmd/msgvault/cmd/person.go +++ b/cmd/msgvault/cmd/person.go @@ -208,6 +208,429 @@ 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: "Split selected merged participant 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; repeat for multiple participants") + 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 { + if len(participantIDs) == 0 { + return usageErr(cmd, errors.New("at least one participant ID is required")) + } + 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 +681,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..cdb7543fa 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,235 @@ 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{} + 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")) + 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") + 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", + } { + assertions.Equal(2, 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: "split participants", command: newPersonSplitCommand(), + args: []string{"1", "--merge-id", "1", "--revision", "1", + "--idempotency-key", "split-key"}, + want: "at least one participant ID is required", + }, + { + 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/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..7a10cc43a --- /dev/null +++ b/internal/store/person_merge_snapshot_test.go @@ -0,0 +1,254 @@ +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) + assert := assert.New(t) + st, err := Open(filepath.Join(t.TempDir(), "inventory.db")) + require.NoError(err) + t.Cleanup(func() { _ = st.Close() }) + require.NoError(st.InitSchema()) + + actual := make([]string, 0) + tables, err := st.db.Query(`SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`) + require.NoError(err) + for tables.Next() { + var table string + require.NoError(tables.Scan(&table)) + foreignKeys, queryErr := st.db.Query(`SELECT "from", "table" + FROM pragma_foreign_key_list(?) ORDER BY "from"`, table) + require.NoError(queryErr, "foreign keys for %s", table) + for foreignKeys.Next() { + var column, target string + require.NoError(foreignKeys.Scan(&column, &target)) + if target == "persons" { + actual = append(actual, table+"."+column) + } + } + require.NoError(foreignKeys.Err()) + require.NoError(foreignKeys.Close()) + } + require.NoError(tables.Err()) + require.NoError(tables.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..7c53e11e3 --- /dev/null +++ b/internal/store/person_merge_validation_internal_test.go @@ -0,0 +1,82 @@ +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: "empty participants", mutate: func(r *PersonSplitRequest) { r.ParticipantIDs = nil }}, + {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) { + request := valid + request.ParticipantIDs = append([]int64(nil), valid.ParticipantIDs...) + tc.mutate(&request) + err := request.validate() + require.Error(t, err) + assert.ErrorIs(t, err, ErrPersonMergeInvalid) + }) + } + + require.NoError(t, valid.validate()) + assert.Equal(t, []int64{3, 4}, valid.canonicalParticipantIDs()) + assert.Equal(t, []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..b1e47e306 --- /dev/null +++ b/internal/store/person_merges.go @@ -0,0 +1,2231 @@ +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 moves selected absorbed-origin participant lineages from +// a merged person into a newly created person. +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 len(r.ParticipantIDs) == 0: + return fmt.Errorf("%w: at least one participant is required", 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..1db6cdfff --- /dev/null +++ b/internal/store/person_splits.go @@ -0,0 +1,2298 @@ +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 moves selected absorbed-origin participant +// lineages from a merged person into a fresh person. 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 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) + } + } + + 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 + sourceBindings := 0 + matched := 0 + alreadySplit := false + survivorLineageIntact := true + for _, item := range lineage { + if item.personID.Valid && item.personID.Int64 == sourceID { + sourceBindings++ + } + 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 sourceBindings <= len(selected) { + 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 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..a6159b995 --- /dev/null +++ b/internal/store/person_splits_test.go @@ -0,0 +1,2735 @@ +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_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/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..95b968b5e 100644 --- a/internal/store/subset.go +++ b/internal/store/subset.go @@ -2,10 +2,12 @@ package store import ( "database/sql" + "encoding/json" "errors" "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -16,15 +18,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 +97,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 +531,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 +558,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 +583,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 +704,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 +852,744 @@ 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 { + 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) + } + if _, err := tx.Exec(`UPDATE sqlite_sequence SET seq = CASE + WHEN seq < ? THEN ? ELSE seq END WHERE name = 'persons'`, + historicalPersonID, historicalPersonID); err != nil { + return fmt.Errorf("advance subset person sequence: %w", err) + } + if _, err := tx.Exec(`INSERT INTO sqlite_sequence (name, seq) + SELECT 'persons', ? WHERE NOT EXISTS ( + SELECT 1 FROM sqlite_sequence WHERE name = 'persons' + )`, historicalPersonID); err != nil { + return fmt.Errorf("initialize subset person sequence: %w", 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 +1680,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 +1897,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 +2052,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 +2079,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 +2175,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..67ecc361c 100644 --- a/internal/store/subset_test.go +++ b/internal/store/subset_test.go @@ -35,6 +35,1016 @@ 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 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 +1164,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 +1181,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 +4667,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?: { From 87a9e85181d35eab495ebab5a63bb79ef90b038c Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 23 Aug 2026 07:27:07 -0500 Subject: [PATCH 2/4] fix(people): reserve subset split restoration IDs Complete merge packets promise that a later split can recreate rows with their original database IDs. A subset previously reserved only historical person IDs, so new destination rows could take an omitted relationship or profile-row ID and make reversal fail. Reserve every AUTOINCREMENT ID carried by copied snapshots. Schema introspection keeps the reservation aligned with the restorable table set as that set changes. Generated with Codex Co-authored-by: Codex --- internal/store/subset.go | 118 ++++++++++++++++++++++++++++------ internal/store/subset_test.go | 66 +++++++++++++++++++ 2 files changed, 165 insertions(+), 19 deletions(-) diff --git a/internal/store/subset.go b/internal/store/subset.go index 95b968b5e..f1d317c33 100644 --- a/internal/store/subset.go +++ b/internal/store/subset.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" "time" @@ -1025,26 +1026,105 @@ func copyPersonMergePackets( return fmt.Errorf("copy person merge review candidates: %w", err) } if selectedPackets > 0 { - 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) - } - if _, err := tx.Exec(`UPDATE sqlite_sequence SET seq = CASE - WHEN seq < ? THEN ? ELSE seq END WHERE name = 'persons'`, - historicalPersonID, historicalPersonID); err != nil { - return fmt.Errorf("advance subset person sequence: %w", err) - } - if _, err := tx.Exec(`INSERT INTO sqlite_sequence (name, seq) - SELECT 'persons', ? WHERE NOT EXISTS ( - SELECT 1 FROM sqlite_sequence WHERE name = 'persons' - )`, historicalPersonID); err != nil { - return fmt.Errorf("initialize subset person sequence: %w", err) + 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 } diff --git a/internal/store/subset_test.go b/internal/store/subset_test.go index 67ecc361c..062ee6e9b 100644 --- a/internal/store/subset_test.go +++ b/internal/store/subset_test.go @@ -248,6 +248,72 @@ func TestSubsetPersonMergePacketCanSplitAfterNewPersonCreation(t *testing.T) { 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() From 8ce249a04b9f169fb67e174ee41b266113de419f Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 23 Aug 2026 08:51:12 -0500 Subject: [PATCH 3/4] fix(people): reverse zero-participant merges --- cmd/msgvault/cmd/person.go | 7 +- cmd/msgvault/cmd/person_test.go | 33 +++++-- .../person_merge_validation_internal_test.go | 4 +- internal/store/person_merges.go | 7 +- internal/store/person_splits.go | 95 ++++++++++--------- internal/store/person_splits_test.go | 75 +++++++++++++++ 6 files changed, 160 insertions(+), 61 deletions(-) diff --git a/cmd/msgvault/cmd/person.go b/cmd/msgvault/cmd/person.go index 8ab0f88b6..d8f682a9b 100644 --- a/cmd/msgvault/cmd/person.go +++ b/cmd/msgvault/cmd/person.go @@ -292,7 +292,7 @@ func newPersonSplitCommand() *cobra.Command { var jsonOutput bool command := &cobra.Command{ Use: "split ", - Short: "Split selected merged participant lineage into a new person", + 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") @@ -343,7 +343,7 @@ func newPersonSplitCommand() *cobra.Command { } command.Flags().Int64Var(&mergeID, "merge-id", 0, "Merge record to split") command.Flags().Int64SliceVar(&participantIDs, "participant", nil, - "Participant lineage to move; repeat for multiple participants") + "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") @@ -591,9 +591,6 @@ func personCLIIdempotencyKey(cmd *cobra.Command, value string) (string, error) { } func validatePersonCLIParticipants(cmd *cobra.Command, participantIDs []int64) error { - if len(participantIDs) == 0 { - return usageErr(cmd, errors.New("at least one participant ID is required")) - } seen := make(map[int64]struct{}, len(participantIDs)) for _, participantID := range participantIDs { if participantID <= 0 { diff --git a/cmd/msgvault/cmd/person_test.go b/cmd/msgvault/cmd/person_test.go index cdb7543fa..df4aa2628 100644 --- a/cmd/msgvault/cmd/person_test.go +++ b/cmd/msgvault/cmd/person_test.go @@ -205,6 +205,7 @@ func executePersonMergeCLI(t *testing.T, command *cobra.Command, args ...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"}` @@ -236,7 +237,21 @@ func TestPersonMergeCommandsUseConfiguredRemote(t *testing.T) { personJSON, mergeJSON) case "POST /api/v1/people/7/split": assertions.Equal(`"person-7-r4"`, r.Header.Get("If-Match")) - assertions.Equal("remote-split", r.Header.Get("Idempotency-Key")) + 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, @@ -309,6 +324,10 @@ func TestPersonMergeCommandsUseConfiguredRemote(t *testing.T) { 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") @@ -335,7 +354,11 @@ func TestPersonMergeCommandsUseConfiguredRemote(t *testing.T) { "GET /api/v1/person-merges/12/snapshot", "POST /api/v1/person-merge-candidates/21/decision", } { - assertions.Equal(2, requests[key], key) + 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(), @@ -399,12 +422,6 @@ func TestPersonMergeCLIValidationHappensBeforeOpeningStore(t *testing.T) { "--absorbed-revision", "1", "--idempotency-key", "merge-key"}, want: "survivor revision must be a positive integer", }, - { - name: "split participants", command: newPersonSplitCommand(), - args: []string{"1", "--merge-id", "1", "--revision", "1", - "--idempotency-key", "split-key"}, - want: "at least one participant ID is required", - }, { name: "candidate decision", command: newPersonMergeCandidateCommand(), args: []string{"1", "--person-id", "1", "--revision", "1", diff --git a/internal/store/person_merge_validation_internal_test.go b/internal/store/person_merge_validation_internal_test.go index 7c53e11e3..2177f8c94 100644 --- a/internal/store/person_merge_validation_internal_test.go +++ b/internal/store/person_merge_validation_internal_test.go @@ -57,7 +57,6 @@ func TestPersonSplitRequestValidation(t *testing.T) { }{ {name: "source id", mutate: func(r *PersonSplitRequest) { r.SourcePersonID = 0 }}, {name: "merge id", mutate: func(r *PersonSplitRequest) { r.MergeID = 0 }}, - {name: "empty participants", mutate: func(r *PersonSplitRequest) { r.ParticipantIDs = nil }}, {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 }}, @@ -77,6 +76,9 @@ func TestPersonSplitRequestValidation(t *testing.T) { } require.NoError(t, valid.validate()) + rootOnly := valid + rootOnly.ParticipantIDs = nil + require.NoError(t, rootOnly.validate()) assert.Equal(t, []int64{3, 4}, valid.canonicalParticipantIDs()) assert.Equal(t, []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 index b1e47e306..e2e2e6da1 100644 --- a/internal/store/person_merges.go +++ b/internal/store/person_merges.go @@ -67,8 +67,9 @@ func (r PersonMergeRequest) validate() error { } } -// PersonSplitRequest moves selected absorbed-origin participant lineages from -// a merged person into a newly created person. +// 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 @@ -84,8 +85,6 @@ func (r PersonSplitRequest) validate() error { 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 len(r.ParticipantIDs) == 0: - return fmt.Errorf("%w: at least one participant is required", ErrPersonMergeInvalid) case r.ExpectedSourceRevision <= 0: return fmt.Errorf("%w: source revision must be positive", ErrPersonMergeInvalid) case strings.TrimSpace(r.IdempotencyKey) == "": diff --git a/internal/store/person_splits.go b/internal/store/person_splits.go index 1db6cdfff..3b0855a08 100644 --- a/internal/store/person_splits.go +++ b/internal/store/person_splits.go @@ -53,11 +53,12 @@ type personSplitJournalRow struct { postMergeJSON sql.NullString } -// SplitPersonMergeContext moves selected absorbed-origin participant -// lineages from a merged person into a fresh person. 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. +// 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) { @@ -193,21 +194,23 @@ func (s *Store) splitPersonMergeOnce( return fmt.Errorf("insert person split: %w", err) } - 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) + 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{} @@ -290,24 +293,31 @@ func (s *Store) splitPersonMergeOnce( } } - 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) + 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 { @@ -606,14 +616,10 @@ func validatePersonSplitLineage( } absorbedUnsplit := 0 absorbedTotal := 0 - sourceBindings := 0 matched := 0 alreadySplit := false survivorLineageIntact := true for _, item := range lineage { - if item.personID.Valid && item.personID.Int64 == sourceID { - sourceBindings++ - } if item.originSide == personMergeOriginSurvivor && (!item.personID.Valid || item.personID.Int64 != sourceID) { survivorLineageIntact = false @@ -640,7 +646,7 @@ func validatePersonSplitLineage( if alreadySplit { return personSplitLineageSelection{}, ErrPersonMergeAlreadySplit } - if sourceBindings <= len(selected) { + if len(selected) == 0 && absorbedTotal != 0 { return personSplitLineageSelection{}, ErrPersonSplitParticipants } restoresAbsorbed := len(selected) == absorbedUnsplit && absorbedUnsplit == absorbedTotal @@ -662,6 +668,9 @@ func absorbedPersonMergeSnapshotRoot( 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 } diff --git a/internal/store/person_splits_test.go b/internal/store/person_splits_test.go index a6159b995..851bcc5d2 100644 --- a/internal/store/person_splits_test.go +++ b/internal/store/person_splits_test.go @@ -113,6 +113,81 @@ func TestSplitPersonMerge_ExactReversal(t *testing.T) { 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) From 87f199916d7f60a64ae7396fe33b33a5dbb7187d Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 23 Aug 2026 10:54:04 -0500 Subject: [PATCH 4/4] docs(people): define merge reversal boundaries Reversible merge records outlive live profile rows, so later schema changes and subset copies need clear constraints. Document the shipped lifecycle and the migration rule before durable snapshots become a maintenance contract. Run the existing person-reference inventory against PostgreSQL as well as SQLite so backend-specific references cannot bypass it. Use local Testify assertion objects so the validation coverage passes the Go 1.27 lint gate. Generated with Codex Co-authored-by: Codex --- docs/cli-reference.md | 34 ++++++++- docs/internal/person-merge-reversal.md | 71 +++++++++++++++++++ docs/usage/people.md | 53 ++++++++++++++ internal/store/person_merge_snapshot_test.go | 64 +++++++++++------ .../person_merge_validation_internal_test.go | 16 +++-- .../store/pg_maintenance_internal_test.go | 10 ++- 6 files changed, 214 insertions(+), 34 deletions(-) create mode 100644 docs/internal/person-merge-reversal.md 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/store/person_merge_snapshot_test.go b/internal/store/person_merge_snapshot_test.go index 7a10cc43a..58e557619 100644 --- a/internal/store/person_merge_snapshot_test.go +++ b/internal/store/person_merge_snapshot_test.go @@ -110,34 +110,56 @@ func TestCapturePersonMergeSnapshotIncludesRootsBindingsAndReferencedRows(t *tes func TestPersonMergeTableInventoryClassifiesEveryPersonReference(t *testing.T) { require := require.New(t) - assert := assert.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) - tables, err := st.db.Query(`SELECT name FROM sqlite_master - WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`) - require.NoError(err) - for tables.Next() { - var table string - require.NoError(tables.Scan(&table)) - foreignKeys, queryErr := st.db.Query(`SELECT "from", "table" - FROM pragma_foreign_key_list(?) ORDER BY "from"`, table) - require.NoError(queryErr, "foreign keys for %s", table) - for foreignKeys.Next() { - var column, target string - require.NoError(foreignKeys.Scan(&column, &target)) - if target == "persons" { - actual = append(actual, table+"."+column) - } - } - require.NoError(foreignKeys.Err()) - require.NoError(foreignKeys.Close()) + 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(tables.Err()) - require.NoError(tables.Close()) + require.NoError(rows.Err()) + require.NoError(rows.Close()) sort.Strings(actual) classified := make([]string, 0) diff --git a/internal/store/person_merge_validation_internal_test.go b/internal/store/person_merge_validation_internal_test.go index 2177f8c94..c25ac1b5e 100644 --- a/internal/store/person_merge_validation_internal_test.go +++ b/internal/store/person_merge_validation_internal_test.go @@ -66,19 +66,23 @@ func TestPersonSplitRequestValidation(t *testing.T) { } 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(t, err) - assert.ErrorIs(t, err, ErrPersonMergeInvalid) + require.Error(err) + assert.ErrorIs(err, ErrPersonMergeInvalid) }) } - require.NoError(t, valid.validate()) + require := require.New(t) + assert := assert.New(t) + require.NoError(valid.validate()) rootOnly := valid rootOnly.ParticipantIDs = nil - require.NoError(t, rootOnly.validate()) - assert.Equal(t, []int64{3, 4}, valid.canonicalParticipantIDs()) - assert.Equal(t, []int64{4, 3}, valid.ParticipantIDs, "canonicalization must not mutate caller input") + 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/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()