import type { OrgAuth } from "../auth/sf-auth.ts"; import type { SObjectDescribe } from "../describe/types.ts"; /** * Upsert-key detection for Salesforce composite upsert. * * The problem we solve * -------------------- * Plain composite INSERT against a non-empty target sandbox hits * DUPLICATE_VALUE errors on any row whose external-id field collides * with an existing target row. SFDMU's answer is to make the user * pre-configure per-object upsert keys — which users forget, get * wrong, or skip entirely. * * This module picks an upsert key automatically in the unambiguous * case. Scope-locked to the simplest rule that is **strictly not * worse than today's INSERT-only path**: * * - Exactly ONE external-id field on the source object that is * `createable && idLookup && externalId && !autoNumber && * !calculated`, AND * - The target org describes the SAME field with the SAME flags. * * → Use it as the upsert external-id. The composite upsert endpoint * then matches source↔target rows by that field's value and * UPDATEs rather than failing on duplicate. * * Everything else (0 candidates, 2+ candidates, target missing the * field) falls back to INSERT with a logged warning. This is the * scope boundary agreed for this phase — no thresholds, no * persistence, no standard-object defaults, no composite keys, no * user-override UI. * * Why `idLookup` * -------------- * `externalId === true` is necessary but not sufficient — it just * flags the attribute in setup. `idLookup === true` is the flag * Salesforce sets on fields it will actually match against in an * upsert URL. For custom external-id fields the two always move * together; requiring both is belt-and-suspenders and filters out * rare edge cases (deprecated/fls-blocked fields). * * Why `!autoNumber` * ----------------- * Auto-number fields (CaseNumber, OrderNumber) can be marked * External ID and idLookup, but their values are generated per-org — * "matching" a source CaseNumber to a target CaseNumber would be * luck, not identity. Always exclude. * * Why `createable` * ---------------- * If the field isn't createable on the source, we can't send its * value from the source read, so it's useless as an upsert key even * if it exists. Mirrors `pickCreateableFields` in execute.ts. */ /** A field that passes every "could be an upsert key" test on one side (source or target). */ export type UpsertCandidate = { name: string; /** For logs / reports. */ label: string | undefined; }; /** The resolver's verdict for a single object. */ export type UpsertDecision = { kind: "picked"; field: string; } | { kind: "ambiguous"; reason: "no-candidates" | "multiple-candidates" | "target-missing-field" | "target-describe-failed" | "all-candidates-empty" | "override-invalid"; /** Populated for multiple-candidates / all-candidates-empty / override-invalid. */ candidates?: string[]; /** Short human string for logs / the dry-run report. */ detail: string; }; /** * Return every source field that *could* be an upsert key on its own. * Order-stable by describe order so callers that care about "first * candidate" get deterministic output. */ export declare function discoverCandidates(describe: SObjectDescribe): UpsertCandidate[]; /** * Optional inputs that disambiguate the multiple-candidates case. */ export type ResolveUpsertKeyOptions = { /** * Per-candidate-field source population count. Required to auto-pick * when more than one candidate exists. Absent fields are treated as 0. * Caller computes via `SELECT COUNT(field) FROM Object [WHERE scope]` * and threads the map through. */ populationByField?: Map; /** * Total count of source records considered, used to flag the * "all candidates have 0 populated rows" case explicitly. Optional; * absent → we still resolve by relative population (highest wins). */ totalRecords?: number; /** * User override — when provided AND the field is in the candidate list, * we use it verbatim and skip auto-pick. Invalid overrides (field not * a candidate) are reported back via the `ambiguous` decision so the * caller can surface the mistake instead of silently falling back. */ override?: string; /** * The key a PRIOR run against this (source, target) pair used, loaded * from the upsert-key store. Beats auto-pick but loses to an explicit * override. Unlike an override, an invalid sticky key (field gone, * flags drifted, target side ineligible) falls through SILENTLY to * auto-pick — the store records history, the user didn't type it. */ sticky?: string; }; /** * Decide whether to UPSERT or INSERT this object. * * Resolution order: * 1. User override (when provided AND valid). * 2. Sticky key from a prior run (when still valid on BOTH sides) — * keeps re-seeds matching on the same external-id the original run * used, even if scope changes would auto-pick differently. * 3. Single candidate. * 4. Multiple candidates: pick by highest source population, with * alphabetical name as the deterministic tiebreaker. If all * candidates report zero populated source rows we return * `ambiguous: "all-candidates-empty"` so the caller logs and INSERTs * — DUPLICATE_VALUE recovery picks up from there at run time. * 4. Zero candidates / target missing field / target describe failed * → existing ambiguous reasons. * * `targetDescribe` may be `null` — callers that can't or didn't fetch * the target describe (object missing from target entirely, describe * threw) should pass null; we return `ambiguous: "target-describe-failed"` * so the caller logs the gap explicitly. */ export declare function resolveUpsertKey(sourceDescribe: SObjectDescribe, targetDescribe: SObjectDescribe | null, options?: ResolveUpsertKeyOptions): UpsertDecision; /** * Pick the most-populated candidate, breaking ties alphabetically by * field name. Returns `null` when every candidate has zero populated * rows on the source (no signal to disambiguate from). */ export declare function pickByPopulation(candidates: UpsertCandidate[], populationByField: Map): UpsertCandidate | null; /** * One-shot population probe for a candidate set. * * Emits `SELECT COUNT(f0) c0, COUNT(f1) c1, ... FROM [WHERE ...]`, * which Salesforce evaluates as a single aggregate read regardless of the * candidate count. COUNT(field) excludes nulls, so the per-field value is * the populated row count — the signal `pickByPopulation` needs to break * ties between multiple eligible external-id fields. * * AI-boundary note: result is a per-FIELD count map, never row values. * Counts are aggregate metadata — same disclosure shape as `SELECT COUNT()`. */ export declare function queryFieldPopulation(opts: { auth: OrgAuth; object: string; fields: string[]; /** Apply the user's WHERE clause when probing root scope; omit elsewhere. */ whereClause?: string; fetchFn?: typeof fetch; }): Promise>;