AbsoluteJS

Sync Cluster Bus

Fan @absolutejs/sync updates across every node in a multi-instance deployment. Two first-party adapters speak the same ClusterBus interface — Postgres LISTEN/NOTIFY (with overflow spill) and Redis pub/sub (with native geo-replication). Pick by what you're already running.

#Why a Cluster Bus

A single SyncEngine instance fans out changes to its own subscribers in-process. Multiple instances behind a load balancer each see only their own writes — without a cluster bus, two clients hitting different nodes never converge.

1
Node AA node commits a change
The engine applies the write locally and fans out to its own in-process subscribers.
2
BusThe change is published to the bus
engine.connectCluster(bus) wires the commit stream to the bus — every committed change becomes a ClusterMessage envelope broadcast to every other instance.
3
Node BEvery other node re-applies it
Receivers re-apply each message into their own local view, so subscribers on any node see writes from every node.

The bus interface itself is minimal — two methods:

TS
type ClusterBus = {
  publish(message: ClusterMessage): Promise<void>;
  subscribe(
    handler: (message: ClusterMessage) => void,
  ): Promise<() => Promise<void>>;
};

#Adapters

Both adapters implement the same ClusterBus interface, so swapping is one constructor change — the engine doesn't know which bus is underneath.

PackageVersionTransportDescription
@absolutejs/sync-bus-pg0.2.3Postgres LISTEN/NOTIFYHorizontal scale on the Postgres you already run — no extra infrastructure. An overflow spill table handles payloads above the 8KB NOTIFY cap.
@absolutejs/sync-bus-redis0.1.1Redis PUBLISH/SUBSCRIBEFaster fan-out and a native geo-replication story on managed Redis. Works with any Redis client (ioredis, node-redis, …) via a narrow interface.

#Postgres Quick Start

@absolutejs/sync-bus-pg uses pg_notify + LISTEN with an overflow spill table for payloads above the 8KB NOTIFY cap.

TS
import postgres from 'postgres';
import { createPostgresClusterBus } from '@absolutejs/sync-bus-pg';
import { createSyncEngine } from '@absolutejs/sync';

const sql = postgres(process.env.DATABASE_URL!);
const bus = createPostgresClusterBus({ sql });

const engine = createSyncEngine({ instanceId: 'shard-A' });
await engine.connectCluster(bus);

// Optionally prune the spill table on a schedule:
setInterval(() => bus.vacuum(60_000), 60_000);

#Postgres Adapter

Postgres's native pub/sub means no extra infrastructure if you already run PG, and delivery of inline broadcasts is at-least-once on local commit. createPostgresClusterBus() accepts:

OptionDefaultDescription
sqlrequiredA postgres.js client.
channel'absolutejs_sync_cluster'The NOTIFY channel name.
spill'overflow'Strategy for oversized payloads — 'overflow', 'always', or 'never'.
onErrorHook that fires on subscribe-side failures.

Three spill strategies cover different durability / speed trade-offs:

'overflow'defaultInline JSON when the payload is small; table-backed above the inline budget. The best balance for most workloads.
'always'Every message goes through the spill table — durable but slower. Useful for forensic-replay workflows.
'never'Throws on oversized payloads. Useful in tests to assert payload-size discipline.

bus.vacuum(olderThanMs) prunes spill rows older than the cutoff, on a schedule you control. Inline messages never touch the table — only the rare oversized batch does.

Why vacuum instead of delete-on-consume
NOTIFY broadcasts to every listener, including the publisher's own, so a spill row must outlive the broadcast. Deleting on consume would race other listeners off the row — pruning by age is the safe policy.

#Postgres Metrics

bus.metrics() returns cumulative counters since createPostgresClusterBus(). A healthy small-payload workload keeps publishedSpilled near zero.

publishedEnvelopes put on the channel.
publishedInlineSmall payloads sent as direct NOTIFY.
publishedSpilledOversized payloads routed via the spill table.
receivedEnvelopes pulled off the channel.
spillFetchedReceiver fetched a spill row.
spillFetchFailedSpill row was vacuumed before it could be read.
spillVacuumedRows pruned by vacuum().
publishErrorspublish() threw.
subscribeErrorsonError fired.
Watch spillFetchFailed
A climbing spillFetchFailed means vacuum() is racing the receivers — widen the vacuum window or shrink the spill rate.

#Redis Quick Start

@absolutejs/sync-bus-redis uses PUBLISH / SUBSCRIBE with a dedicated subscriber connection.

TS
import Redis from 'ioredis';
import { createRedisClusterBus } from '@absolutejs/sync-bus-redis';
import { createSyncEngine } from '@absolutejs/sync';

const publisher = new Redis(process.env.REDIS_URL!);
const subscriber = publisher.duplicate(); // MUST be a separate connection
const bus = createRedisClusterBus({ publisher, subscriber });

const engine = createSyncEngine({ instanceId: 'shard-A' });
await engine.connectCluster(bus);

#Redis Adapter

No 8KB payload cap, lower latency at high subscriber counts, and native geo-replication on managed Redis. Delivery is at-most-once — a disconnected subscriber misses messages while down; pair with engine.exportChangeLog() for shard-reboot resume. createRedisClusterBus() accepts:

OptionDefaultDescription
publisherrequiredAny RedisCommandClient — used for PUBLISH.
subscriberrequiredA dedicated connection for SUBSCRIBE — publisher.duplicate().
channel'absolutejs_sync_cluster'The pub/sub channel name.
onErrorHook that fires on subscribe-side failures.
The subscriber must be a separate connection
Redis forbids other commands on a subscribed connection, so a single client doing both publish and subscribe deadlocks. ioredis's duplicate() and node-redis's createClient() both give you a second connection.

The narrow RedisPublisher + RedisSubscriber interfaces mean the adapter doesn't peer-dep a specific client — the README shows the wrapping for ioredis (EventEmitter-based) vs node-redis (callback-based).

#Redis Metrics

bus.metrics() returns cumulative counters since createRedisClusterBus():

publishedEnvelopes published.
receivedEnvelopes received.
publishErrorspublish() threw.
subscribeErrorsonError fired.
totalSubscribersReachedSum of Redis's PUBLISH return values across calls.
totalSubscribersReached is the canary
Redis treats "no subscribers" as success, so PUBLISH won't error when the cluster unwires. A drop to 0 when you expect peers means subscribers disconnected — replication lag, a network partition, or a region failover dropping the duplex. Pair with engine.metrics() (totalSubscriptions across instances) to spot the cluster halving silently.

#Choosing an Adapter

Pick by deployment, not in the abstract — the adapter that rides infrastructure you already run is almost always the right one, and swapping later is one constructor change.

sync-bus-pgsync-bus-redis
DeliveryAt-least-once on local commit for inline broadcasts.At-most-once — a disconnected subscriber misses messages while down.
Payload size8KB NOTIFY cap; the spill table carries anything larger.No cap.
Fan-out latencyFine at small subscriber counts.Lower at high subscriber counts (10+).
Cross-regionNot a fit — PG logical replication is too heavy for bus traffic.Native geo-replication on managed Redis (Redis Cluster, ElastiCache Global Datastore, Memorystore, Upstash).
Extra infrastructureNone if you already run Postgres for the durable store.None if you already run Redis for cache / queue / rate-limit.
ReplayThe spill table can hold oversized payloads forensically.Pair with engine.exportChangeLog() for shard-reboot resume.