feat(rnetwork): add Automerge schemas + local-first-client for CRM sync

Contact metadata, relationships, and graph layout positions sync
via CRDT. Delegations remain server-authoritative in PostgreSQL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 17:45:01 -07:00
parent ee54ec219d
commit b86af45610
2 changed files with 220 additions and 0 deletions

View File

@ -0,0 +1,137 @@
/**
* rNetwork Local-First Client
*
* Wraps the shared local-first stack for collaborative CRM data.
* Contact metadata, relationships, and graph layout sync in real-time.
*/
import { DocumentManager } from '../../shared/local-first/document';
import type { DocumentId } from '../../shared/local-first/document';
import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { networkSchema, networkDocId } from './schemas';
import type { NetworkDoc, CrmContact, CrmRelationship } from './schemas';
export class NetworkLocalFirstClient {
#space: string;
#documents: DocumentManager;
#store: EncryptedDocStore;
#sync: DocSyncManager;
#initialized = false;
constructor(space: string, docCrypto?: DocCrypto) {
this.#space = space;
this.#documents = new DocumentManager();
this.#store = new EncryptedDocStore(space, docCrypto);
this.#sync = new DocSyncManager({
documents: this.#documents,
store: this.#store,
});
this.#documents.registerSchema(networkSchema);
}
get isConnected(): boolean { return this.#sync.isConnected; }
async init(): Promise<void> {
if (this.#initialized) return;
await this.#store.open();
const cachedIds = await this.#store.listByModule('network', 'crm');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<NetworkDoc>(docId, networkSchema, binary);
}
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[NetworkClient] Working offline'); }
this.#initialized = true;
}
async subscribe(): Promise<NetworkDoc | null> {
const docId = networkDocId(this.#space) as DocumentId;
let doc = this.#documents.get<NetworkDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<NetworkDoc>(docId, networkSchema, binary)
: this.#documents.open<NetworkDoc>(docId, networkSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getDoc(): NetworkDoc | undefined {
return this.#documents.get<NetworkDoc>(networkDocId(this.#space) as DocumentId);
}
onChange(cb: (doc: NetworkDoc) => void): () => void {
return this.#sync.onChange(networkDocId(this.#space) as DocumentId, cb as (doc: any) => void);
}
onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); }
// ── Contact CRUD ──
saveContact(contact: CrmContact): void {
const docId = networkDocId(this.#space) as DocumentId;
this.#sync.change<NetworkDoc>(docId, `Save contact ${contact.name}`, (d) => {
d.contacts[contact.did] = contact;
});
}
deleteContact(did: string): void {
const docId = networkDocId(this.#space) as DocumentId;
this.#sync.change<NetworkDoc>(docId, `Delete contact`, (d) => {
delete d.contacts[did];
// Clean up relationships involving this contact
for (const key of Object.keys(d.relationships)) {
const rel = d.relationships[key];
if (rel.fromDid === did || rel.toDid === did) delete d.relationships[key];
}
});
}
// ── Relationship CRUD ──
saveRelationship(relationship: CrmRelationship): void {
const docId = networkDocId(this.#space) as DocumentId;
const key = `${relationship.fromDid}:${relationship.toDid}`;
this.#sync.change<NetworkDoc>(docId, `Save relationship`, (d) => {
d.relationships[key] = relationship;
});
}
deleteRelationship(fromDid: string, toDid: string): void {
const docId = networkDocId(this.#space) as DocumentId;
const key = `${fromDid}:${toDid}`;
this.#sync.change<NetworkDoc>(docId, `Delete relationship`, (d) => {
delete d.relationships[key];
});
}
// ── Graph Layout ──
saveGraphLayout(positions: Record<string, { x: number; y: number }>, zoom: number, panX: number, panY: number): void {
const docId = networkDocId(this.#space) as DocumentId;
this.#sync.change<NetworkDoc>(docId, `Save layout`, (d) => {
d.graphLayout.positions = positions as any;
d.graphLayout.zoom = zoom;
d.graphLayout.panX = panX;
d.graphLayout.panY = panY;
});
}
saveNodePosition(did: string, x: number, y: number): void {
const docId = networkDocId(this.#space) as DocumentId;
this.#sync.change<NetworkDoc>(docId, `Move node`, (d) => {
if (!d.graphLayout.positions) d.graphLayout.positions = {} as any;
d.graphLayout.positions[did] = { x, y } as any;
});
}
async disconnect(): Promise<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -0,0 +1,83 @@
/**
* rNetwork Automerge document schemas.
*
* Stores CRM relationship metadata for collaborative network management.
* Delegations remain in PostgreSQL (trust-engine); this doc syncs
* contact info, relationship notes, and graph layout positions.
*
* DocId format: {space}:network:crm
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Document types ──
export interface CrmContact {
did: string;
name: string;
role: string;
tags: string[];
addedBy: string | null;
addedAt: number;
}
export interface CrmRelationship {
fromDid: string;
toDid: string;
type: string;
weight: number;
note: string;
}
export interface GraphLayout {
positions: Record<string, { x: number; y: number }>;
zoom: number;
panX: number;
panY: number;
}
export interface NetworkDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
contacts: Record<string, CrmContact>;
relationships: Record<string, CrmRelationship>;
graphLayout: GraphLayout;
}
// ── Schema registration ──
export const networkSchema: DocSchema<NetworkDoc> = {
module: 'network',
collection: 'crm',
version: 1,
init: (): NetworkDoc => ({
meta: {
module: 'network',
collection: 'crm',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
contacts: {},
relationships: {},
graphLayout: { positions: {}, zoom: 1, panX: 0, panY: 0 },
}),
migrate: (doc: any, _fromVersion: number) => {
if (!doc.contacts) doc.contacts = {};
if (!doc.relationships) doc.relationships = {};
if (!doc.graphLayout) doc.graphLayout = { positions: {}, zoom: 1, panX: 0, panY: 0 };
doc.meta.version = 1;
return doc;
},
};
// ── Helpers ──
export function networkDocId(space: string) {
return `${space}:network:crm` as const;
}