import { derivePlayRowIdentity, derivePlayRowIdentityFromKey, // Relative (not '@shared_libs/...') because this file ships inside the // packed SDK's dist/bundling-sources graph (reachable from the // packaged runner entry), where only relative imports resolve. } from '../plays/row-identity'; import type { MapExecutionScope } from './ctx-types'; export type ExplicitMapKeyResolver = ( row: Record, index: number, ) => string; export type ExplicitMapKeyInput = | string | readonly string[] | ((row: Record, index: number) => unknown); export function stripMapFieldOutputs( row: Record, fieldNames: readonly string[], ): Record { return Object.fromEntries( Object.entries(row).filter( ([fieldName]) => !fieldNames.includes(fieldName), ), ); } export function createExplicitMapKeyResolver(input: { mapNamespace: string; fieldNames: readonly string[]; key: ExplicitMapKeyInput | null | undefined; }): ExplicitMapKeyResolver | null { if (!input.key) { return null; } const key = input.key; return (row, index) => { const stableRow = stripMapFieldOutputs(row, input.fieldNames); let raw: unknown; if (typeof key === 'function') { raw = key(stableRow, index); } else if (typeof key === 'string') { raw = stableRow[key]; } else { raw = key.map((fieldName) => stableRow[fieldName]); } if (raw === null || raw === undefined) { throw new Error( `ctx.dataset("${input.mapNamespace}") key function returned ${raw === null ? 'null' : 'undefined'} for row ${index}. ` + 'Use a non-empty stable input column (e.g. { key: "email" }) or return a non-empty string, number, or tuple.', ); } const asString = normalizeExplicitMapKey(raw); if (!asString) { throw new Error( `ctx.dataset("${input.mapNamespace}") key function returned an empty value for row ${index}. ` + 'Use non-empty stable input columns or return a non-empty string, number, or tuple.', ); } return asString; }; } function normalizeExplicitMapKey(value: unknown): string { if (Array.isArray(value)) { const parts = value.map((entry) => normalizeExplicitMapKeyPart(entry)); return parts.every(Boolean) ? JSON.stringify(parts) : ''; } return normalizeExplicitMapKeyPart(value); } function normalizeExplicitMapKeyPart(value: unknown): string { if (typeof value === 'number') { return Number.isFinite(value) ? String(value) : ''; } return String(value ?? '').trim(); } /** * Maximum number of distinct duplicate keys retained in dedupe metadata for * observability. Keeps log lines bounded when many keys collide. */ export const DEDUPE_DUPLICATE_KEY_SAMPLE_CAP = 5; export interface DedupeExplicitMapKeyResult { /** Rows with duplicates removed; first occurrence per key kept, order preserved. */ rows: TRow[]; /** Count of rows dropped because an earlier row produced the same key. */ droppedCount: number; /** Capped sample of the duplicate key values that were deduped. */ duplicateKeys: string[]; } /** * Resolver shape used by {@link dedupeExplicitMapKeyRows}. Receives each row in * its original (pre-dedupe) position. Callers that hold output rows in a * different form than `Record` adapt to this signature with a * thin wrapper. */ export type DedupeKeyResolver = (row: TRow, index: number) => string; /** * Silent dedupe for explicit `ctx.dataset(...)` map keys. Keeps the first row * for each canonical key and drops subsequent duplicates, preserving original * order. With no resolver this is a pure passthrough (a fresh array copy with * empty metadata). Replaces the prior throw-on-duplicate behavior per product * decision: duplicate keys should never fail the run. */ export function dedupeExplicitMapKeyRows(input: { rows: readonly TRow[]; resolver: DedupeKeyResolver | null; }): DedupeExplicitMapKeyResult { if (!input.resolver) { return { rows: [...input.rows], droppedCount: 0, duplicateKeys: [] }; } const resolver = input.resolver; const seenKeys = new Set(); const duplicateKeys: string[] = []; const seenDuplicateKeys = new Set(); const rows: TRow[] = []; for (let index = 0; index < input.rows.length; index += 1) { const row = input.rows[index]!; const keyValue = resolver(row, index); if (seenKeys.has(keyValue)) { if ( !seenDuplicateKeys.has(keyValue) && duplicateKeys.length < DEDUPE_DUPLICATE_KEY_SAMPLE_CAP ) { duplicateKeys.push(keyValue); } seenDuplicateKeys.add(keyValue); continue; } seenKeys.add(keyValue); rows.push(row); } return { rows, droppedCount: input.rows.length - rows.length, duplicateKeys, }; } export function deriveMapRowIdentity(input: { row: Record; index?: number; artifactTableNamespace: string; fieldNames?: readonly string[]; explicitKey?: ExplicitMapKeyResolver | null; }): string { const stableRow = stripMapFieldOutputs(input.row, input.fieldNames ?? []); return input.explicitKey ? derivePlayRowIdentityFromKey( input.explicitKey(stableRow, input.index ?? 0), input.artifactTableNamespace, ) : derivePlayRowIdentity(stableRow, input.artifactTableNamespace); } export class MapRowIdentity { private nextInvocationIndex: number; constructor(startInvocationIndex = 0) { this.nextInvocationIndex = startInvocationIndex; } get invocationIndex(): number { return this.nextInvocationIndex; } set invocationIndex(value: number) { this.nextInvocationIndex = value; } createScope(input: { logicalNamespace: string; artifactTableNamespace: string; mapNodeId?: string | null; fieldNames?: readonly string[]; explicitKey?: ExplicitMapKeyResolver | null; }): MapExecutionScope { const mapInvocationId = `${input.logicalNamespace}:${this.nextInvocationIndex}`; this.nextInvocationIndex += 1; const explicitKey = input.explicitKey ?? null; const fieldNames = input.fieldNames ?? []; return { mapInvocationId, mapNodeId: input.mapNodeId ?? null, logicalNamespace: input.logicalNamespace, artifactTableNamespace: input.artifactTableNamespace, rowIdentity: (row, index) => deriveMapRowIdentity({ row, index, artifactTableNamespace: input.artifactTableNamespace, fieldNames, explicitKey, }), }; } }