diff --git a/modules/rnetwork/local-first-client.ts b/modules/rnetwork/local-first-client.ts new file mode 100644 index 0000000..15f32d3 --- /dev/null +++ b/modules/rnetwork/local-first-client.ts @@ -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 { + 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(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 { + const docId = networkDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, networkSchema, binary) + : this.#documents.open(docId, networkSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDoc(): NetworkDoc | undefined { + return this.#documents.get(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(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(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(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(docId, `Delete relationship`, (d) => { + delete d.relationships[key]; + }); + } + + // ── Graph Layout ── + + saveGraphLayout(positions: Record, zoom: number, panX: number, panY: number): void { + const docId = networkDocId(this.#space) as DocumentId; + this.#sync.change(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(docId, `Move node`, (d) => { + if (!d.graphLayout.positions) d.graphLayout.positions = {} as any; + d.graphLayout.positions[did] = { x, y } as any; + }); + } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rnetwork/schemas.ts b/modules/rnetwork/schemas.ts new file mode 100644 index 0000000..92abb64 --- /dev/null +++ b/modules/rnetwork/schemas.ts @@ -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; + zoom: number; + panX: number; + panY: number; +} + +export interface NetworkDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + contacts: Record; + relationships: Record; + graphLayout: GraphLayout; +} + +// ── Schema registration ── + +export const networkSchema: DocSchema = { + 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; +}