The WAS replication driver for RxDB: the changes-feed pull handler, the conditional-write push handler with its conflict assembler, the conflict-handler seam, the synced-document schema, the writer-id mint, and the session controller core. For the browser, Node.js, and React Native.
A WAS Collection is replicated into a local RxDB replica by pulling the
changes feed and pushing conditional writes back. This library owns that
driver: mapping the feed's wire documents into replica documents and applying
the checkpoint rule, routing each local change to the content endpoint, the
metadata endpoint, or a delete, assembling the conflict entry RxDB asks for when
a conditional write is refused with 412, recovering the one benign 412 (a
delete refused on a drifted validator whose body is unchanged), writing each
accepted write's acked state (the validator, and the write's stamp and
createdBy when the server answers with a body) back into the local row, and
running one replication per collection for a session behind a serialized start
and stop.
It was extracted from the two copies that had drifted apart in
@interop/was-react and in the Freewallet browser wallet, neither of which may
depend on the other. The placement is recorded in
decisions/0001 and the
current shape in ARCHITECTURE.md.
What deliberately lives elsewhere: the WAS HTTP client, the sync port, the wire
vocabulary, the error classes, and the err.name predicates that classify them
(@interop/was-client, its /sync subpath); the last-write-wins comparison
rule itself (@interop/social-core); document ciphers, key epochs, and every
other kind of key handling (@interop/was-client/edv and each consuming app);
and the change engine a replica-less wallet drives instead of RxDB
(@interop/wallet-core/sync).
@interop/was-sync-- the types, the synced-document schema, the conflict-handler seam and its last-write-wins default, and the writer-id mint. Free of RxDB in its module graph AND in its emitted declarations, so an app that never builds a replica resolves it withrxdbabsent.@interop/was-sync/rxdb-- the pull and push handlers, thereplicateRxCollectionwiring, the opt-in feed-backed conflict re-read, and the controller core.rxdbis this subpath's peer dependency (declared optional, since only this subpath needs it).@interop/was-sync/testing-- test fixtures. It loads withoutrxdbtoo.
@interop/was-client is a peer dependency rather than a dependency, so the
consumer's single range decides which copy resolves. The package constructs none
of its error classes and matches every one of them by err.name.
The synced-document schema is documentation unless a consumer registers a
validator. This package registers none, and its bounds (maxLength, minimum)
describe what the server mints. The server's write stamp is stored as received,
with no boundary guard. A consumer that wraps its storage in a validator takes
on the consequence that a malformed server stamp wedges the pull.
The react-native export condition is carried on all three subpaths, but it is
forward-looking: there is no React Native consumer of this driver today, and
RxDB's own React Native story is not exercised here. The writer-id mint takes an
injected storage port rather than reaching for localStorage, which is part of
what keeps the root entry loadable there.
The driver moves stored bodies verbatim and holds no key material. data is the
stored content body (plaintext JSON, or an EDV envelope on an encrypted
collection) and custom is the stored metadata body; encrypting and decrypting
stay above this layer. The one place a decision cannot be body-opaque is a
mutable-head conflict, whose sides have to be compared: that decision is
injected as a closure, so no cipher, key, or descriptor reaches this package. On
an encrypted collection that closure should be a refreshing cipher's decrypt
(createRefreshingEdvDocCipher from @interop/was-client/edv), which carries
the once-per-session unknown-epoch re-read; the driver runs no refresh itself.
Each side is decrypted under its own row id, so the cipher's check that an
envelope was written for the resource it is being read under still holds here. A
side that does not decrypt for want of a key is presumed newer and warned about;
one that fails that integrity check fails the replication cycle instead.
The writer id is an unkeyed, clearable, unrecoverable attribution label, never an identity: it derives from no secret, and it can vanish and be re-minted with nothing carried over.
Test fixtures ship on the @interop/was-sync/testing subpath: a stub sync port
and memory implementations of the controller's timer and reachability ports.
They are for tests only. Keep the subpath out of production import globs (an
eslint no-restricted-imports pattern is what each consumer uses). There is no
fake WAS server; the integration suite runs against a live in-process
was-teaching-server.
- Node.js 24+ is recommended.
To install via PNPM:
pnpm install @interop/was-sync
An app that builds a local replica also installs the rxdb peer:
pnpm install rxdb
To install locally (for development):
git clone https://github.com/interop-alliance/was-sync.git
cd was-sync
pnpm install
The schema and the conflict handler are collection-creation options:
import { makeLwwConflictHandler, syncedDocSchema } from '@interop/was-sync'
await database.addCollections({
contacts: {
schema: syncedDocSchema(),
// Mutable-head collections need a rule; a content-addressed collection
// takes RxDB's default handler instead.
// The closure is was-client's own `DocCipher.decrypt`; the resolver
// addresses each side by the row's own id.
conflictHandler: makeLwwConflictHandler(options => cipher.decrypt(options))
}
})A whole session's replication runs behind the controller core:
import { createSyncController } from '@interop/was-sync/rxdb'
const controller = createSyncController({
port: {
wasClient,
spaceId,
baseUrl,
collections: [{ key: 'contacts', id: 'contacts' }],
rxCollection: key => database.collections[key]
},
onStatus: (key, collectionId, status) => setStatus(collectionId, status),
onlineSource: {
isOnline: () => navigator.onLine,
subscribe: onOnline => {
window.addEventListener('online', onOnline)
return () => window.removeEventListener('online', onOnline)
}
},
pollMs: 30_000
})
await controller.start()
// ... and on logout. `stop()` is terminal for an instance: a session that
// replicates again constructs a fresh controller.
await controller.stop()pollMs is required rather than defaulted, because two consumers poll at
different rates and a package default would silently change one app's background
request rate.
An optional writerId (also accepted by createWasReplication) is the WAS
writer-attribution label. When it is set, every content write and delete
declares it as the Writer-Id header. A metadata write sends none, because the
label belongs to the content record alone. When it is absent, content writes and
deletes declare none, which clears any stored label on the server. The app mints
and keeps the label (getWriterId is a helper for that); a session that must
not reveal a stable label to the host leaves it absent or passes a per-session
one. The label is advisory and never an identity.
The pull side stores the feed's label for each revision on the row as
SyncedDoc.writerId, tombstones included, whoever declared it. It names the
writer of the latest revision the row learned from the server. A local edit
leaves the previous label in place until the push's ack write-back or echo
replaces it, so it is no proof of who made a local edit.
The package logs through a structural Logger port (four two-arg methods:
debug, info, warn, error, each taking a static message and an optional
data object, with data.err reserved for an Error). An app that never wires
one gets a console fallback prefixed [was-sync]. To route the package's events
into the app's own sinks, install a logger once at bootstrap:
import { createLogger } from '@interop/logger'
import { setLogger } from '@interop/was-sync'
setLogger(createLogger('sync'))setLogger returns the previously installed logger, so a test can restore it in
afterEach. @interop/logger is not a dependency of this package; any object
with the four methods works.
PRs accepted. See CONTRIBUTING.md for editor setup (Prettier, ESLint, and EditorConfig) and how it maps to CI.
If editing the Readme, please conform to the standard-readme specification.
MIT License © 2026 Interop Alliance.