When to Use Conflict Resolvers
Use conflict resolvers when standard LWW semantics don’t fit your business requirements. Common use cases include booking systems, inventory management, and data integrity constraints.
The Problem with LWW
Last-Write-Wins (LWW) works great for many scenarios, but fails for business-critical cases:
// The LWW Problem: Two users book the same seat concurrently
// User A: book(seat: "A1", user: "alice") → timestamp: 1000
// User B: book(seat: "A1", user: "bob") → timestamp: 1001
// LWW Result: seat A1 → bob (alice's booking silently lost!)
// Correct Result: Reject bob's booking, seat already taken Basic Usage
Register a conflict resolver to implement custom merge logic:
import { TopGunClient } from '@topgunbuild/client';
const client = new TopGunClient({
serverUrl: 'ws://localhost:8080',
});
// Get conflict resolver client
const resolvers = client.getConflictResolvers();
// Register a first-write-wins resolver for bookings
await resolvers.register('bookings', {
name: 'first-write-wins',
code: `
// Reject if seat is already booked
if (context.localValue !== undefined) {
return {
action: 'reject',
reason: 'Seat already booked by ' + context.localValue.user
};
}
return { action: 'accept', value: context.remoteValue };
`,
priority: 100,
});
// Handle rejections
resolvers.onRejection((rejection) => {
console.log(`Booking failed for ${rejection.key}: ${rejection.reason}`);
}); MergeContext API
Your resolver receives a context object with all information needed to make a decision:
interface MergeContext<V> {
/** Map name being modified */
mapName: string;
/** Entry key being modified */
key: string;
/** Current server value (undefined if key doesn't exist) */
localValue: V | undefined;
/** Incoming client value (null for deletions) */
remoteValue: V;
/** Local HLC timestamp */
localTimestamp?: Timestamp;
/** Remote HLC timestamp */
remoteTimestamp: Timestamp;
/** Client/node ID that sent the update */
remoteNodeId: string;
/** Authentication context */
auth?: {
userId?: string;
roles?: string[];
metadata?: Record<string, unknown>;
};
/** Read other entries for cross-key validation */
readEntry: (key: string) => V | undefined;
} MergeResult Actions
Your resolver must return one of these actions:
type MergeResult<V> =
| { action: 'accept'; value: V } // Accept remote value
| { action: 'reject'; reason: string } // Reject with error message
| { action: 'merge'; value: V } // Use custom merged value
| { action: 'local' }; // Keep local value, skip this resolver | Action | Description |
|---|---|
accept | Accept the remote value as-is |
reject | Reject the operation and notify the client |
merge | Apply a custom merged value |
local | Keep the local value, pass to next resolver |
Built-in Resolvers
TopGun provides common resolvers out of the box:
import { BuiltInResolvers } from '@topgunbuild/core';
// First-Write-Wins - reject if value exists
// Use case: Booking systems, unique constraints
server.registerResolver('seats', BuiltInResolvers.FIRST_WRITE_WINS());
// Immutable - reject any modification after initial write
// Use case: Audit logs, receipts
server.registerResolver('receipts', BuiltInResolvers.IMMUTABLE());
// Non-Negative - reject if value would be negative
// Use case: Inventory, balances
server.registerResolver('inventory', BuiltInResolvers.NON_NEGATIVE());
// Numeric Min/Max - keep lowest/highest value
// Use case: Auctions, high scores
server.registerResolver('bids', BuiltInResolvers.NUMERIC_MIN());
server.registerResolver('scores', BuiltInResolvers.NUMERIC_MAX());
// Array Union - merge arrays by union
// Use case: Tags, categories
server.registerResolver('tags', BuiltInResolvers.ARRAY_UNION());
// Deep Merge - recursively merge objects
// Use case: User profiles with concurrent field updates
server.registerResolver('profiles', BuiltInResolvers.DEEP_MERGE());
// Owner Only - only creator can modify
// Use case: User-owned documents
server.registerResolver('documents', BuiltInResolvers.OWNER_ONLY());
// Server Only - only server can write
// Use case: System configuration
server.registerResolver('config', BuiltInResolvers.SERVER_ONLY());
// Version Increment - optimistic locking
// Use case: Concurrent editing with conflict detection
server.registerResolver('articles', BuiltInResolvers.VERSION_INCREMENT()); Use Cases
Inventory Management
Prevent stock from going negative:
// Inventory system - prevent negative stock
await resolvers.register('inventory', {
name: 'stock-validation',
code: `
const newStock = context.remoteValue?.stock;
// Reject negative stock
if (typeof newStock === 'number' && newStock < 0) {
return { action: 'reject', reason: 'Stock cannot be negative' };
}
return { action: 'accept', value: context.remoteValue };
`,
priority: 90,
}); Cross-Key Validation
Validate against other entries in the same map:
// Budget check - validate cart total doesn't exceed budget
await resolvers.register('cart', {
name: 'budget-check',
code: `
// Read user's budget from another key
const budget = context.readEntry('user_budget');
if (!budget) {
return { action: 'accept', value: context.remoteValue };
}
// Calculate cart total
const itemPrice = context.remoteValue?.price ?? 0;
const currentTotal = context.localValue?.total ?? 0;
const newTotal = currentTotal + itemPrice;
if (newTotal > budget.amount) {
return {
action: 'reject',
reason: 'Cart total exceeds budget'
};
}
return { action: 'accept', value: context.remoteValue };
`,
priority: 80,
}); Deletion Protection
Resolvers also receive deletions (when remoteValue is null), allowing you to protect entries:
// Protect entries from unauthorized deletion
await resolvers.register('protected-data', {
name: 'delete-protection',
code: `
// Check if this is a deletion (remoteValue is null)
if (context.remoteValue === null) {
// Only admins can delete
if (!context.auth?.roles?.includes('admin')) {
return { action: 'reject', reason: 'Only admins can delete entries' };
}
}
return { action: 'accept', value: context.remoteValue };
`,
priority: 100,
}); Deletion Handling
Deletions are passed through resolvers with remoteValue: null.
This allows IMMUTABLE, OWNER_ONLY, and custom resolvers to protect entries from unauthorized deletion.
Key Patterns
Apply resolvers to specific keys using glob patterns:
// Apply resolver only to specific keys using glob patterns
await resolvers.register('data', {
name: 'user-protection',
code: `
if (context.localValue !== undefined) {
return { action: 'reject', reason: 'User record exists' };
}
return { action: 'accept', value: context.remoteValue };
`,
keyPattern: 'user:*', // Only applies to keys like user:123, user:abc
priority: 100,
});
// Other keys in the same map are not affected
// post:123 → normal LWW behavior
// user:456 → uses the resolver above Supported patterns:
*- matches any characters?- matches single character
React Integration
Use the useConflictResolver and useMergeRejections hooks:
import { useConflictResolver, useMergeRejections } from '@topgunbuild/react';
function BookingForm({ eventId }) {
const { register, loading, error } = useConflictResolver(`bookings:${eventId}`);
const { lastRejection, clear } = useMergeRejections({
mapName: `bookings:${eventId}`
});
// Register resolver on mount
useEffect(() => {
register({
name: 'first-write-wins',
code: `
if (context.localValue !== undefined) {
return { action: 'reject', reason: 'Seat already booked' };
}
return { action: 'accept', value: context.remoteValue };
`,
priority: 100,
});
}, []);
// Show rejection notification
useEffect(() => {
if (lastRejection) {
toast.error(`Booking failed: ${lastRejection.reason}`);
clear();
}
}, [lastRejection]);
return (
<div>
{loading && <span>Registering resolver...</span>}
{error && <span>Error: {error.message}</span>}
{/* Booking form UI */}
</div>
);
} Priority System
Resolvers execute in priority order (highest first). Use priorities to layer validation logic:
| Priority | Use Case |
|---|---|
| 100 | Security constraints (immutable, owner-only) |
| 90 | Business rules (non-negative, quotas) |
| 80 | Validation (format, cross-key checks) |
| 50 | Merge strategies (deep merge, array union) |
| 0 | LWW fallback (automatic) |
Security Considerations
Sandboxed Execution
Client-registered resolvers run in a sandboxed environment (isolated-vm when available).
The following are forbidden in resolver code: eval, Function,
fetch, require, import, process,
setTimeout, setInterval.
Design Decisions
In-Memory Storage: Resolvers are stored in memory only. Clients re-register resolvers on connection. This simplifies architecture and ensures clients control their own conflict resolution logic.
Permission Model: Resolver registration requires PUT permission on the target map. If you can write to a map, you can define how your writes are resolved.
Rate Limiting: The server limits resolvers per map (default: 100) and code size (default: 50KB).
API Reference
Client API
const resolvers = client.getConflictResolvers();
// Register a resolver
await resolvers.register(mapName, {
name: string;
code: string;
priority?: number; // 0-100, default 50
keyPattern?: string;
});
// Unregister a resolver
await resolvers.unregister(mapName, resolverName);
// List registered resolvers
const list = await resolvers.list(mapName?);
// Listen for rejections
const unsubscribe = resolvers.onRejection((rejection) => {
console.log(rejection.mapName, rejection.key, rejection.reason);
}); React Hooks
// Manage resolvers for a map
const { register, unregister, list, loading, error, registered } =
useConflictResolver(mapName, { autoUnregister: true });
// Listen for rejections
const { rejections, lastRejection, clear } =
useMergeRejections({ mapName, maxHistory: 100 }); Comparison with Alternatives
| Feature | TopGun | Replicache | Convex |
|---|---|---|---|
| Conflict detection | HLC-based | Server mutation | Transactions |
| Custom resolution | Server hooks | Mutator logic | ACID only |
| Client notification | MERGE_REJECTED | Optimistic rollback | Error throw |
| Cross-key validation | readEntry() | Full DB access | Full DB access |
| Sandboxed execution | isolated-vm | N/A (server code) | N/A (serverless) |