* Merges two pending mutations for the same item within a transaction * * Merge behavior truth table: * - (insert, update) → insert (merge changes, keep empty original) * - (insert, delete) → null (cancel both mutations) * - (update, delete) → delete (delete dominates) * - (update, update) → u
( existing: PendingMutation<T>, incoming: PendingMutation<T>, )
| 41 | * @returns The merged mutation, or null if both should be removed |
| 42 | */ |
| 43 | function mergePendingMutations<T extends object>( |
| 44 | existing: PendingMutation<T>, |
| 45 | incoming: PendingMutation<T>, |
| 46 | ): PendingMutation<T> | null { |
| 47 | // Truth table implementation |
| 48 | switch (`${existing.type}-${incoming.type}` as const) { |
| 49 | case `insert-update`: { |
| 50 | // Update after insert: keep as insert but merge changes |
| 51 | // For insert-update, the key should remain the same since collections don't allow key changes |
| 52 | return { |
| 53 | ...existing, |
| 54 | type: `insert` as const, |
| 55 | original: {}, |
| 56 | modified: incoming.modified, |
| 57 | changes: { ...existing.changes, ...incoming.changes }, |
| 58 | // Keep existing keys (key changes not allowed in updates) |
| 59 | key: existing.key, |
| 60 | globalKey: existing.globalKey, |
| 61 | // Merge metadata (last-write-wins) |
| 62 | metadata: incoming.metadata ?? existing.metadata, |
| 63 | syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata }, |
| 64 | // Update tracking info |
| 65 | mutationId: incoming.mutationId, |
| 66 | updatedAt: incoming.updatedAt, |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | case `insert-delete`: |
| 71 | // Delete after insert: cancel both mutations |
| 72 | return null |
| 73 | |
| 74 | case `update-delete`: |
| 75 | // Delete after update: delete dominates |
| 76 | return incoming |
| 77 | |
| 78 | case `update-update`: { |
| 79 | // Update after update: replace with latest, union changes |
| 80 | return { |
| 81 | ...incoming, |
| 82 | // Keep original from first update |
| 83 | original: existing.original, |
| 84 | // Union the changes from both updates |
| 85 | changes: { ...existing.changes, ...incoming.changes }, |
| 86 | // Merge metadata |
| 87 | metadata: incoming.metadata ?? existing.metadata, |
| 88 | syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata }, |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | case `delete-delete`: |
| 93 | case `insert-insert`: |
| 94 | // Same type: replace with latest |
| 95 | return incoming |
| 96 | |
| 97 | default: { |
| 98 | // Exhaustiveness check |
| 99 | const _exhaustive: never = `${existing.type}-${incoming.type}` as never |
| 100 | throw new Error(`Unhandled mutation combination: ${_exhaustive}`) |