Skip to content

About

Stop echo loops in two-way syncs between two systems: origin tagging, content-hash comparison and a suppression window. TypeScript, zero dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

sync-loop-guard

CI License: MIT

Stop echo loops in two-way syncs between two systems, for example a CRM and an ERP or e-commerce platform. Three independent guards (origin tagging, content-hash comparison and a short suppression window), a pluggable store, and no runtime dependencies. TypeScript, Node 20 or later.

import { createGuard } from "sync-loop-guard";

const guard = createGuard({ origin: "sync-bot" });

const decision = await guard.shouldPropagate({
  source: "crm",
  recordId: "42",
  fields: { email: "ada@example.com" },
  origin: "user:alice",
});
if (decision.propagate) {
  await guard.recordWrite({ target: "shop", recordId: "42", fields: decision.fields });
  // ...now write decision.fields to the shop
}

The failure mode

A two-way sync listens for changes in both systems and copies them across. Each copy is itself a change, so the other system reports it, and the sync copies it back:

        change ──▶ webhook ──▶ sync ──▶ write
        in CRM                          to Shop
          ▲                               │
          │                               ▼
        write ◀── sync ◀── webhook ◀── change
        to CRM                         in Shop

Left alone this is one of two things:

  • An endless loop. Every round trip burns API quota, fills logs, and ends when a rate limit or an error stops it.
  • A short loop that damages data. The echo stops after one round trip, but while it was in flight someone edited the record again. The stale echo overwrites their edit.

The fix is for the sync to recognise its own writes when they come back. No single check does that reliably across systems, because each system differs in what it tells you about a change and in how it rewrites values. So this library combines three.

The three guards

Guard Drops an event when... Needs Weak spot
Origin tagging it says the sync made the change the source reports who or what changed a record systems that do not say; a shared service account used by people and the sync
Content-hash comparison its values equal the last synced values a store that survives restarts and is shared by workers values the other system reformats
Suppression window the sync wrote that record and field to that system moments ago a store with expiry a real edit inside the window is lost

1. Origin tagging

Every write the sync makes carries a marker: an integration user, an API client name, a header, or a dedicated field such as syncOrigin. When an event arrives carrying that marker, the sync made the change, and it is dropped.

Use it when the system reports the origin of a change on its webhook. It is exact, needs no state, and costs nothing. It is not enough on its own because many systems do not report an origin on every object, and if the sync writes as the same user that people log in as, you cannot tell them apart.

2. Content-hash comparison

After each write the sync stores a hash of the value, per record and field. When an event arrives it hashes the incoming value and drops the field if it matches. Only fields that really changed are passed on.

Use it when the system does not report an origin, when webhooks carry the whole record every time anything on it changes, and when the same webhook is delivered twice. It also keeps unrelated edits from rewriting every field. It needs a store that outlives the process, and it only works if both sides hold the value the same way. If the other system trims, re-cases or reformats on save, pass normalize so those differences do not count.

3. Suppression window

When the sync writes a field to a system, it ignores that system's events for the same record and field for a short time, 10 seconds by default.

Use it when the other system rewrites values in ways you cannot normalise (so the hash will not match), or when the echo can arrive before you have saved the new state. It is the bluntest guard: an edit a person makes in that system inside the window is dropped too. Keep the window short, longer than the time the echo takes to arrive and no longer, and keep the key narrow, which here is one record and one field in one system.

Which to use

Use all three unless you know why not. They fail in different places, so each covers the others' gaps:

  • The system reports an origin and never reformats values: origin tagging alone is enough. Pass windowMs: 0, compareHashes: false.
  • No origin, values stored as sent: content-hash comparison. Pass windowMs: 0.
  • Values are reformatted and you cannot normalise them: the window, with hashes as a backstop. Without a window, the hash approach needs one extra write to converge, then stops.

The test suite (test/simulation.test.ts) runs two simulated systems that fire a webhook for every write and shows each case: no guard loops until a cap is hit, each guard stops it in its own situation.

Install

npm install github:yourprivatedevs/sync-loop-guard

Not on the npm registry; npm installs it from GitHub and builds it on install. Requires Node 20 or later. ES modules only.

Usage

Call shouldPropagate when an event arrives from either system. If it says to propagate, call recordWrite before writing to the other system, because the echo webhook can arrive before your write call returns. recordWrite hands back an undo function, so a failed write does not leave behind a record that claims the value was synced.

import { createGuard } from "sync-loop-guard";

const guard = createGuard({
  origin: "sync-bot",                       // the tag the sync stamps on its writes
  windowMs: 10_000,
  normalize: (field, value) =>
    field === "email" && typeof value === "string" ? value.trim().toLowerCase() : value,
  // store: yourSharedStore,                // see "Store" below
});

// CRM webhook handler. The shop handler is the mirror image.
export async function onCrmChange(payload: CrmWebhook): Promise<void> {
  const recordId = payload.customerId;

  const decision = await guard.shouldPropagate({
    source: "crm",
    recordId,
    fields: { email: payload.email, phone: payload.phone },
    origin: payload.changedBy,              // whatever the CRM reports as the actor
  });
  if (!decision.propagate) {
    console.log("not propagating", decision.dropped);   // { email: "suppressed" }, ...
    return;
  }

  const undo = await guard.recordWrite({ target: "shop", recordId, fields: decision.fields });
  try {
    await shop.updateCustomer(recordId, { ...decision.fields, syncOrigin: guard.origin });
  } catch (error) {
    await undo();
    throw error;
  }
}

recordId must be the same on both sides. If the two systems use different ids, map them to one before calling the guard.

API

createGuard(options?)

Option Default Meaning
origin off Tag the sync stamps on its writes. Events carrying it are dropped.
windowMs 10000 Suppression window in milliseconds. 0 turns it off.
compareHashes true Drop values equal to the last synced value.
normalize none (field, value) => value, applied before hashing.
store MemoryStore Where state is kept.

Returns a guard with:

  • guard.origin: the configured tag, to stamp on writes.
  • guard.shouldPropagate(event): resolves to a Decision. Reads state only.
  • guard.recordWrite(write): records an outgoing write and resolves to an async undo function.

An event is { source, recordId, fields, origin? }. A write is { target, recordId, fields }.

A Decision is:

{
  propagate: boolean;                    // write decision.fields to the other system
  fields: Record<string, unknown>;       // the fields that really changed
  dropped: Record<string, "own-origin" | "suppressed" | "unchanged">;  // the rest, and why
}

Checks run in order: origin first, which drops the whole event, then per field the window, then the hash. Values are compared as JSON, with object keys sorted. undefined and null count as the same empty value. Dates compare by their ISO string. Use normalize for anything else.

Store

The guard keeps its state in a GuardStore, which has three methods and may be synchronous or async:

interface GuardStore {
  get(key: string): string | undefined | Promise<string | undefined>;
  set(key: string, value: string, ttlMs?: number): void | Promise<void>;
  delete(key: string): void | Promise<void>;
}

MemoryStore is the in-process implementation, for tests, prototypes and a single worker. It loses its state on restart. With more than one worker, or anything that restarts, use a shared store, or an echo handled by a different worker looks like a new change. A Redis adapter is a few lines, for example with ioredis:

import type { GuardStore } from "sync-loop-guard";
import type { Redis } from "ioredis";

export const redisStore = (redis: Redis): GuardStore => ({
  get: async (key) => (await redis.get(key)) ?? undefined,
  set: async (key, value, ttlMs) => {
    if (ttlMs === undefined) await redis.set(key, value);
    else await redis.set(key, value, "PX", ttlMs);
  },
  delete: async (key) => {
    await redis.del(key);
  },
});

MemoryStore takes { now } for a controllable clock in tests: new MemoryStore({ now: () => fakeTime }).

What it does not do

  • Conflict resolution. If two people change the same field in both systems at once, something still has to decide which wins. This library only stops the sync from echoing its own writes.
  • Webhook authentication or delivery ids. Verify signatures and drop duplicate deliveries by id yourself if the system provides ids. Content hashes also absorb repeated deliveries of the same value.
  • Nested merging. A field is one value. To sync part of an object, make that part its own field.

Development

npm install
npm test        # compiles with tsc, then runs node:test
npm run build   # emits dist/

License

MIT, copyright Your Private Dev. Built by Your Private Dev, a one-person engineering service for workflow automation, AI agents and integrations.

About

Stop echo loops in two-way syncs between two systems: origin tagging, content-hash comparison and a suppression window. TypeScript, zero dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages