{"_id":"@bernierllc/merge-planner","_rev":"2-4ed767bd4d59fe747b1eb2712e3d95ff","name":"@bernierllc/merge-planner","dist-tags":{"latest":"0.2.1"},"versions":{"0.0.1":{"name":"@bernierllc/merge-planner","version":"0.0.1","keywords":["oidc","trusted-publishing","setup"],"_id":"@bernierllc/merge-planner@0.0.1","maintainers":[{"name":"alikhan410","email":"mczeyo@gmail.com"},{"name":"mkbernier","email":"mkbernier@gmail.com"}],"dist":{"shasum":"029614ef0320a5d5522bb3ade33e1b840445d961","tarball":"https://registry.npmjs.org/@bernierllc/merge-planner/-/merge-planner-0.0.1.tgz","fileCount":2,"integrity":"sha512-TyaCyzuv0hw2kDMrtvHIHaN1pLaLoeqPjt8+jOgIN/v8Vgdh/KPlXKm7PYysVZhrOQGMO6VnijCVy9PNrLR3bw==","signatures":[{"sig":"MEUCIQDDMaNpw1rp000Diw+KPtUaLa1s283E1WdIno0FkGzBUQIgX/wCBjBeGATs61qQBeuCvMoiD2F4ut9l798gst8IGBA=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":2063},"_npmUser":{"name":"mkbernier","email":"mkbernier@gmail.com"},"_npmVersion":"11.12.1","description":"OIDC trusted publishing setup package for @bernierllc/merge-planner","directories":{},"_nodeVersion":"25.9.0","_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/merge-planner_0.0.1_1781125081776_0.9091715033026835","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"_id":"@bernierllc/merge-planner@0.2.1","bugs":{"url":"https://github.com/bernierllc/tools/issues"},"dist":{"shasum":"d60056261ec4a9b322633de73c48632e27d9563f","tarball":"https://registry.npmjs.org/@bernierllc/merge-planner/-/merge-planner-0.2.1.tgz","fileCount":23,"integrity":"sha512-3e/ZOsQGXC4dTJXFdjgIlhsn+G7K9tabc6f7a7yqPBZ+sEg25yVh+dcd/JYpycCvQTHBFc0s2cMH1Whb0rVGmg==","signatures":[{"sig":"MEUCIAFjtFUzNACMeIfAf4EDMvPtyOTzJVQCTlv9dft2iUsmAiEA4FaQw2WFxhx8K1PMPjEcWkIs7qN2lT9Crq6/EuzUkuM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIAMbSMI7aYBuXNzvPSXMlIRwi2esghN2Pt8kLNrRSqZAAiBg5U8+Bt7DGnWkA8SocrKe61pmYfmDm6T8VtH802557A=="}],"unpackedSize":44463},"main":"dist/index.js","name":"@bernierllc/merge-planner","_from":"file:bernierllc-merge-planner-0.2.1.tgz","types":"dist/index.d.ts","author":{"name":"Bernier LLC"},"engines":{"node":">=18.0.0"},"license":"Bernier LLC","scripts":{"lint":"eslint src/**/*.ts","test":"jest","build":"tsc","clean":"rm -rf dist","prebuild":"npm run clean","test:watch":"jest --watch","test:coverage":"jest --coverage"},"version":"0.2.1","_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"bb35986f-2ddb-48fd-b5dd-ca8ac63485a0"}},"homepage":"https://github.com/bernierllc/tools#readme","keywords":["merge","conflict-detection","merge-plan","import","bernierllc"],"_resolved":"/home/runner/work/tools/tools/packages/core/merge-planner/bernierllc-merge-planner-0.2.1.tgz","_integrity":"sha512-3e/ZOsQGXC4dTJXFdjgIlhsn+G7K9tabc6f7a7yqPBZ+sEg25yVh+dcd/JYpycCvQTHBFc0s2cMH1Whb0rVGmg==","repository":{"url":"git+https://github.com/bernierllc/tools.git","type":"git","directory":"packages/core/merge-planner"},"_npmVersion":"12.2.0","description":"Pure conflict detection and declarative merge planning for record imports","directories":{},"maintainers":[{"name":"alikhan410","email":"mczeyo@gmail.com"},{"name":"mkbernier","email":"mkbernier@gmail.com"}],"_nodeVersion":"24.21.0","dependencies":{"@bernierllc/csv-mapper":"0.7.1"},"publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"},"_hasShrinkwrap":false,"devDependencies":{"jest":"^29.7.0","eslint":"^8.0.0","rimraf":"^5.0.0","ts-jest":"^29.1.2","typescript":"^5.3.3","@types/jest":"^29.5.12","@types/node":"^20.11.19"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/merge-planner_0.2.1_1790902369906_0.8444614891489051"}}},"time":{"created":"2026-06-10T20:58:01.592Z","modified":"2026-10-02T00:52:50.213Z","0.0.1":"2026-06-10T20:58:01.917Z","0.2.1":"2026-10-02T00:52:50.037Z"},"keywords":["merge","conflict-detection","merge-plan","import","bernierllc"],"description":"Pure conflict detection and declarative merge planning for record imports","maintainers":[{"name":"alikhan410","email":"mczeyo@gmail.com"},{"name":"mkbernier","email":"mkbernier@gmail.com"}],"readme":"# @bernierllc/merge-planner\n\nPure conflict-detection and declarative merge-planning for record imports. Zero runtime dependencies. Fully generic over `Row` and `Existing` type parameters.\n\n## Overview\n\nGiven an incoming CSV row (post-mapping), a matched existing record, and a schema, this package:\n\n1. **Detects conflicts** (`detectConflicts`): identifies `mergeable` fields that actually differ between the incoming row and the existing record.\n2. **Compiles a merge plan** (`buildMergePlan`): transforms operator decisions into a declarative `MergePlan` that the host Persister can apply directly.\n\nAll functions are pure and synchronous. No I/O, no async paths.\n\n## Installation\n\n```bash\nnpm install @bernierllc/merge-planner\n```\n\n## Quick Start\n\n```typescript\nimport { detectConflicts, buildMergePlan } from '@bernierllc/merge-planner';\nimport type { UploaderSchema, RecordResolution } from '@bernierllc/merge-planner';\n\n// 1. Define your schema\nconst schema: UploaderSchema = {\n  fields: [\n    { targetField: 'id',    label: 'ID',    identity: true,  mergeable: false },\n    { targetField: 'name',  label: 'Name',  identity: false, mergeable: true  },\n    { targetField: 'email', label: 'Email', identity: false, mergeable: true  },\n    { targetField: 'notes', label: 'Notes', identity: false, mergeable: true  },\n  ],\n  autoFillEmpty: true, // default: true\n};\n\n// 2. Detect conflicts between incoming row and existing record\nconst row = { id: '1', name: 'Alice B.', email: 'alice@example.com', notes: 'new note' };\nconst existing = { id: '1', name: 'Alice Smith', email: 'alice@example.com', notes: '' };\n\nconst conflicts = detectConflicts(row, existing, schema);\n// → [{ field: 'name', label: 'Name', incoming: 'Alice B.', existing: 'Alice Smith' }]\n// (notes is auto-filled — existing is empty — so it does NOT appear as a conflict)\n\n// 3. Collect operator decisions (e.g. from UI)\nconst resolution: RecordResolution<typeof existing> = {\n  rowIndex: 0,\n  action: 'merge',\n  target: existing,\n  conflicts,\n  decisions: { name: 'use-incoming' },\n};\n\n// 4. Compile into a declarative plan\nconst plan = buildMergePlan([resolution], schema);\n// plan.merges[0].patch = { name: 'Alice B.', notes: 'new note' }\n// (notes auto-filled because existing was empty)\n\n// 5. Host Persister applies the plan (csv-import-service handles this)\n// persister.merge(plan.merges);\n// persister.create(plan.creates);\n// plan.skips → ignore\n```\n\n## API Reference\n\n### `detectConflicts<Row, Existing>(row, existing, schema): FieldConflict[]`\n\nDetects which fields have a genuine conflict between an incoming row and a matched existing record.\n\nA `FieldConflict` is created for a field when **all** of the following hold:\n\n1. The schema marks the field as `mergeable: true`.\n2. The field is **not** an identity field (`identity !== true`).\n3. The incoming row has a non-empty value for that field.\n4. The existing record has a non-empty value for that field (or `autoFillEmpty` is `false`).\n5. The values differ (strings are NFC-normalized before comparison).\n\n**Auto-fill logic**: When `schema.autoFillEmpty` is `true` (default), fields where the existing record is empty but the incoming row has a value are **not** treated as conflicts — they are automatically included in the merge patch by `buildMergePlan`.\n\n**Identity fields** are never conflicts — they were used to find the match.\n\n### `buildMergePlan<Row, Existing>(resolutions, schema): MergePlan<Existing>`\n\nCompiles a list of operator `RecordResolution` values into a declarative `MergePlan`.\n\nFor each resolution:\n- `'create'` → assembles row data from `conflicts[].incoming`, adds to `creates[]`.\n- `'merge'` → computes `MergePatch` from `decisions` + auto-fills, adds to `merges[]`.\n- `'skip'` → adds `rowIndex` to `skips[]`.\n\n**Throws** `MergePlannerError` with code `INVALID_RESOLUTION` if:\n- A `'merge'` resolution is missing its `target`.\n- Any `rowIndex` is negative.\n\n### `UploaderSchema`\n\n```typescript\ninterface UploaderSchema {\n  fields: UploaderSchemaField[];\n  /**\n   * When true (default), existing-empty fields are auto-filled from incoming\n   * without requiring an operator decision. Set to false to require explicit\n   * decisions for all fields.\n   */\n  autoFillEmpty?: boolean;\n}\n```\n\n### `UploaderSchemaField`\n\nA subset of `FieldMapping` from `@bernierllc/csv-mapper`:\n\n```typescript\ntype UploaderSchemaField = Pick<FieldMapping, 'targetField' | 'identity' | 'mergeable' | 'label'>;\n```\n\n### `FieldConflict`\n\n```typescript\ninterface FieldConflict {\n  field: string;    // targetField key\n  label: string;    // display label (falls back to targetField)\n  incoming: unknown;\n  existing: unknown;\n}\n```\n\n### `FieldDecision`\n\n```typescript\ntype FieldDecision = 'keep-existing' | 'use-incoming' | 'skip-field';\n```\n\n### `RecordResolution<Existing>`\n\n```typescript\ninterface RecordResolution<Existing = Record<string, unknown>> {\n  rowIndex: number;\n  action: 'create' | 'merge' | 'skip';\n  target?: Existing;       // Required when action === 'merge'\n  conflicts: FieldConflict[];\n  decisions: Record<string, FieldDecision>;\n}\n```\n\n### `MergePlan<Existing>`\n\n```typescript\ninterface MergePlan<Existing = Record<string, unknown>> {\n  creates: Array<{ rowIndex: number; data: Record<string, unknown> }>;\n  merges: MergePatch<Existing>[];\n  skips: number[];\n  compiledAt: string;  // ISO 8601 timestamp\n}\n```\n\n### `MergePatch<Existing>`\n\n```typescript\ninterface MergePatch<Existing = Record<string, unknown>> {\n  rowIndex: number;\n  target: Existing;\n  patch: Record<string, unknown>;  // Only fields that will change\n}\n```\n\n## Empty Value Definition\n\n\"Empty\" is defined as: `undefined`, `null`, empty string `\"\"`, or a whitespace-only string (after `.trim()`). This definition applies consistently in both `detectConflicts` and `buildMergePlan`.\n\nNon-string, non-null/undefined values (numbers, booleans, objects) are **never** considered empty.\n\n## autoFillEmpty Semantics\n\n| `autoFillEmpty` | Existing value | Incoming value | Result in `detectConflicts` | Result in `buildMergePlan` patch |\n|:---:|:---:|:---:|:---:|:---:|\n| `true` (default) | empty | non-empty | Not a conflict | Auto-included in patch |\n| `true` (default) | non-empty | non-empty, differs | Conflict | Per operator decision |\n| `false` | empty | non-empty | Conflict | Per operator decision |\n| either | any | empty | Not a conflict | Not in patch |\n\n## Unicode Support\n\nStrings are NFC-normalized before equality comparison in `detectConflicts`. This means NFC and NFD representations of the same character (e.g. `é` as U+00E9 vs `e` + combining acute) are treated as equal and do not produce a conflict.\n\n## Error Handling\n\n| Class | Code | Description | Retryable |\n|-------|------|-------------|-----------|\n| `MergePlannerError` | `INVALID_RESOLUTION` | A `'merge'` resolution is missing its `target`, or a `rowIndex` is negative | No |\n| `MergePlannerError` | `INVALID_SCHEMA` | The schema is structurally invalid | No |\n| `MergePlannerError` | `INVALID_INPUT` | General invalid input (default code) | No |\n\nAll errors include `Error.cause` chaining for full stack preservation.\n\n```typescript\nimport { MergePlannerError } from '@bernierllc/merge-planner';\n\ntry {\n  const plan = buildMergePlan(resolutions, schema);\n} catch (err) {\n  if (err instanceof MergePlannerError) {\n    console.error(`[${err.code}] ${err.message}`, err.context);\n  }\n  throw err; // never swallow\n}\n```\n\n## Data-Flow Contract\n\n```\ndetectConflicts(row, existing, schema)\n    → FieldConflict[]\n    → Operator reviews conflicts, populates decisions\n    → RecordResolution{ rowIndex, action, target?, conflicts, decisions }\n\nbuildMergePlan(resolutions, schema)\n    → MergePlan{ creates[], merges[], skips[], compiledAt }\n    → Host Persister applies creates[] and merges[]\n```\n\n`merge-planner` is the sole owner of conflict detection and plan compilation. `csv-import-service` owns applying the plan (via `applyMergePlan` and `Persister`).\n\n## Integration\n\nTypes from this package are consumed by:\n- `@bernierllc/csv-import-service` — calls `detectConflicts` and `buildMergePlan` in the pipeline.\n- `@bernierllc/csv-ui` — uses `FieldConflict`, `FieldDecision`, `MergePlan` types in the `ConflictResolution` component.\n\n## License\n\nBernier LLC — see [LICENSE](./LICENSE).\n","readmeFilename":"README.md","homepage":"https://github.com/bernierllc/tools#readme","repository":{"url":"git+https://github.com/bernierllc/tools.git","type":"git","directory":"packages/core/merge-planner"},"author":{"name":"Bernier LLC"},"bugs":{"url":"https://github.com/bernierllc/tools/issues"},"license":"Bernier LLC"}