@nanolink/mirrors
v1.1.34
Published
GraphQL subscription client + mirror synchronization utilities.
Readme
@nanolink/mirrors
GraphQL subscription client + in‑memory mirror synchronization utilities. Optimized for incremental change streams that send START / UPDATED / DELETED / DONE / VERSION_ERROR frames.
Features
- Lightweight
SubscriptionClientaroundgraphql-wswith explicit connect, controlled reconnect, and small event surface. - Dual version support in
MirrorSync(versionnumeric + optionalopVersionstring) for hybrid sequence + causality ordering (either dimension can drive resync logic when present). - Stale delete & update guards: ignores events older in either version dimension to prevent resurrecting removed or outdated entities.
- Efficient updates: UPDATED replaces item wholesale only when newer; no deep merge overhead.
- Full sync cycle handling via START/DONE gates;
loadedpromise resolves after first DONE and re-arms on VERSION_ERROR. - Automatic resubscribe after reconnect using last known versions (no duplicate inserts).
- Read‑only delegated map interface for consumers (prevents accidental mutation of internal state).
Connectionhelper manages multiple mirrors, re‑emitting namespaced events (mirror:start,mirror:updated, ...).- Proxy-aware WebSocket resolution: if proxy env vars are present (ALL_PROXY / HTTPS_PROXY / HTTP_PROXY / GLOBAL_AGENT_HTTP_PROXY) the client prefers the Node
wsimplementation; otherwise uses existing global WebSocket (browser / Node >=18) or falls back tows. - Minimal dependencies; event system via
eventemitter3.
Install
npm install @nanolink/mirrorsOptional (proxy via global-agent for generic HTTP(S) requests—WebSocket selection is still handled automatically as described):
// enableProxy.js
import 'global-agent/bootstrap';
process.env.GLOBAL_AGENT_HTTP_PROXY = 'http://proxy:3128';Run with:
node -r global-agent/bootstrap app.jsUsage
What is a mirror?
A mirror is an in-memory, read-only view of a server-side collection. The server sends GraphQL subscription frames with START, UPDATED, DELETED, DONE, and VERSION_ERROR messages; MirrorSync applies those messages to a local Map keyed by id.
Each mirror tracks the latest cursor it has seen. Older subscriptions use numeric version / deleteVersion; newer subscriptions may use string opVersion / deleteOpVersion. MirrorSync supports both cursor styles and resubscribes from the latest known cursor after reconnect.
Connection manages multiple named mirrors. It creates MirrorSync instances, stores them in Connection.Mirrors, and re-emits mirror events as mirror:<event> with the mirror name included.
Quick connection with a predefined mirror
import { Connection } from '@nanolink/mirrors';
const conn = new Connection();
conn.InitConnection('wss://api.example.com/ws', async () => ({
authToken: 'TOKEN',
}));
conn.on('connected', () => console.log('socket up'));
conn.on('mirror:updated', e => console.log('updated', e.mirrorName, e.doc.id));
conn.connect();
async function main() {
const trackers = await conn.getPredefinedMirror('trackers');
console.log('Initial size', trackers.size);
for (const tracker of trackers.values()) {
console.log(tracker);
}
}
main();Custom mirror subscription
const users = await conn.getMirror('users', `
subscription Users($version: Long, $opVersion: String) {
users(version: $version, opVersion: $opVersion) {
type
total
deleteId
deleteVersion
deleteOpVersion
data { id version opVersion name }
}
}
`, {});
console.log('Initial size', users.size);
for (const user of users.values()) {
console.log(user);
}Accessing the MirrorSync instance
getMirror() and getPredefinedMirror() wait for the initial DONE frame and return a ReadonlyMapView. If you need mirror-specific events or stats, use registerMirror() directly.
const mirror = conn.registerMirror('users', usersSubscription, {});
mirror.on('updated', e => console.log('user updated', e.doc.id));
mirror.on('deleted', e => console.log('user deleted', e.orgDoc.id));
const users = await mirror.load();
console.log(mirror.stats(), users.size);Waiting for a specific mirror change
Use waitForUpdated() or waitForDeleted() when a command or mutation must wait until the local mirror has observed the resulting server-side change. Create the wait promise before sending the command, then await it after the command completes.
const updated = mirror.waitForUpdated(referenceId);
await saveReferenceMutation(referenceId);
const { doc, orgDoc } = await updated;
console.log('Mirror caught up', doc.id, orgDoc?.version, doc.version);Both methods accept either an id or a predicate, and default to a 120 second timeout.
await mirror.waitForUpdated(
event => event.doc.externalIds?.some((id: any) => id.key === 'SAP_ID' && id.value === sapId),
{ timeoutMs: 30_000 }
);
await mirror.waitForDeleted(referenceId);Numeric‑only subscriptions (most common)
Most Nanolink GraphQL subscription fields expose only a numeric version and omit opVersion. MirrorSync handles this seamlessly: pass just $version in the query and the server responses won’t include opVersion / deleteOpVersion fields.
// Numeric-only example
const products = await conn.getMirror('products', `
subscription Products($version: Long) {
products(version: $version) {
type
total
deleteId
deleteVersion
data { id version title }
}
}
`, {});
await products.loaded; // after first DONE
console.log('Products count', products.size);When opVersion fields are absent they are simply ignored; ordering & stale protections rely on numeric version only.
Direct low-level client
import { SubscriptionClient } from '@nanolink/mirrors';
const sc = new SubscriptionClient();
sc.InitClient({
url: 'wss://api.example.com/ws',
connectionParams: async () => ({ authToken: 'TOKEN' }),
maxReconnectAttempts: 10,
});
sc.connect();
sc.on('connected', () => {
const dispose = sc.clientsubscribe({
query: 'subscription Ping { ping }'
}, {
next: (msg) => console.log(msg),
error: (e) => console.error('err', e),
complete: () => console.log('done')
});
});Events
SubscriptionClient emits:
- connecting
- connected (first successful connect)
- reconnected (subsequent successful connect after a disconnect)
- disconnected ({ code, reason, wasClean })
- retry ({ attempt }) before a reconnect attempt delay
- error (network/protocol)
Connection re‑emits mirror events as mirror:<event> with payload { mirrorName, ... }:
- start
- updated (only when a newer item actually replaced stored data)
- deleted
- done (end of full sync batch)
- versionError (triggered resync)
- resubscribe (automatic after reconnect)
- error
- removed (mirror explicitly removed)
- cleared (mirror internal state cleared)
API Surface
SubscriptionClient– low level websocket subscription wrapper.MirrorSync– single mirror controller (dual version tracking).Connection– manages multiple named mirrors and re-emits namespaced mirror events.ReadonlyMapView– read-only view returned bygetMirror(),getPredefinedMirror(), andMirrorSync.load().waitForUpdated()/waitForDeleted()– wait until the mirror observes a specific future update or delete event.
Note about mirror helpers
getMirror() is available for custom ad-hoc mirrors but is seldom used in most integrations. The more commonly used helper is getPredefinedMirror() which returns mirrors for known server-side definitions (IDs and field payload shapes) and avoids having to supply the raw GraphQL subscription yourself. Check src/definitions for available predefined mirror names and subscription fragments.
Notes
- Always call
connect()explicitly; no implicit lazy connect. - Call
InitConnection()onConnection, orInitClient()onSubscriptionClient, before connecting. - VERSION_ERROR triggers automatic full resync (re-arms
loaded). - First top-level field in GraphQL subscription payload is treated as the sync envelope.
- Full sync updates are buffered until
DONE, then swapped into the live mirror. - Provide
webSocketImplmanually if bundling for environments without a global WebSocket and you do NOT wantwsas fallback. - When proxy env vars are set in Node,
SubscriptionClientprefersws(allowing external agent configuration); browsers ignore these env vars.
Build & Publish
TypeScript sources compile to dist/.
Scripts:
npm run build # compile
npm run publish:dry # preview publish contents
npm run release # build + publish (public)Developer notes (recent refactor)
This repository recently split GraphQL subscription template literals into per-property modules to make maintenance easier:
- Subscription templates:
src/definitions/subscriptions/*.ts— one file per subscription property. - Shared fragments:
src/definitions/fragments.ts— common fragment string constants used by the subscription files. - Compatibility surface:
src/definitions/mirrors.tsnow re-exports the assembledSubscriptions,RequiredMirrors,TempSubscriptions, and the fragment constants to preserve the original API.
Packaging and what is published
- The npm package only ships the compiled build output.
package.jsonlistsdistanddist-compatin thefilesfield, so the raw TypeScript source files undersrc/(includingsrc/definitions/subscriptions/*.ts) are not included in the published package by default. - The TypeScript compiler emits JavaScript (to
dist) and declaration files (.d.ts) when you run the build; those compiled artifacts are what go into the package.
How to verify locally (PowerShell)
- Build the project:
npm run build- Inspect the compiled
disttree to see the compiled outputs for the subscription modules:
Get-ChildItem -Recurse .\dist | Select-Object FullName- See exactly what would be published (dry-run):
npm pack --dry-runIf you want .ts sources included in the published package, add src to the files array in package.json or add a copy step that places sources in dist/dist-compat before publishing; then verify with npm pack --dry-run.
CI / GitHub Actions
This repository includes a workflow that publishes the package to npm when changes are pushed to main and when a GitHub Release is published. The workflow expects a repository secret named NPM_TOKEN containing a valid npm automation token.
To create and add the secret:
- Generate an npm token on https://www.npmjs.com/ under your account settings (Access Tokens -> Automation).
- In the GitHub repository, go to Settings → Secrets → Actions and add a new secret named
NPM_TOKENwith the token value.
The workflow builds both dist and dist-compat and then runs npm publish --access public. If you need to restrict publishing (for example, to skip on regular pushes), adjust the workflow triggers in .github/workflows/publish.yml.
License
MIT
