diff --git a/modules/rdesign/local-first-client.ts b/modules/rdesign/local-first-client.ts new file mode 100644 index 0000000..d8a900f --- /dev/null +++ b/modules/rdesign/local-first-client.ts @@ -0,0 +1,55 @@ +/** + * rDesign Local-First Client — syncs linked Affine projects. + */ + +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 { designSchema, designDocId } from './schemas'; +import type { DesignDoc, LinkedProject } from './schemas'; + +export class DesignLocalFirstClient { + #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(designSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('design', 'projects'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) this.#documents.open(docId, designSchema, binary); + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + try { await this.#sync.connect(`${proto}//${location.host}/ws/${this.#space}`, this.#space); } catch {} + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = designDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { const b = await this.#store.load(docId); doc = b ? this.#documents.open(docId, designSchema, b) : this.#documents.open(docId, designSchema); } + await this.#sync.subscribe([docId]); return doc ?? null; + } + + getDoc(): DesignDoc | undefined { return this.#documents.get(designDocId(this.#space) as DocumentId); } + onChange(cb: (doc: DesignDoc) => void): () => void { return this.#sync.onChange(designDocId(this.#space) as DocumentId, cb as (doc: any) => void); } + + linkProject(project: LinkedProject): void { + this.#sync.change(designDocId(this.#space) as DocumentId, `Link ${project.name}`, (d) => { d.linkedProjects[project.id] = project; }); + } + unlinkProject(id: string): void { + this.#sync.change(designDocId(this.#space) as DocumentId, `Unlink project`, (d) => { delete d.linkedProjects[id]; }); + } + + async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); } +} diff --git a/modules/rdesign/schemas.ts b/modules/rdesign/schemas.ts new file mode 100644 index 0000000..269ce69 --- /dev/null +++ b/modules/rdesign/schemas.ts @@ -0,0 +1,36 @@ +/** + * rDesign Automerge document schemas. + * + * Syncs linked Affine design projects per space. + * Actual design data lives in the Affine instance. + * + * DocId format: {space}:design:projects + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +export interface LinkedProject { + id: string; + url: string; + name: string; + addedBy: string | null; + addedAt: number; +} + +export interface DesignDoc { + meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; + linkedProjects: Record; +} + +export const designSchema: DocSchema = { + module: 'design', + collection: 'projects', + version: 1, + init: (): DesignDoc => ({ + meta: { module: 'design', collection: 'projects', version: 1, spaceSlug: '', createdAt: Date.now() }, + linkedProjects: {}, + }), + migrate: (doc: any) => { if (!doc.linkedProjects) doc.linkedProjects = {}; doc.meta.version = 1; return doc; }, +}; + +export function designDocId(space: string) { return `${space}:design:projects` as const; } diff --git a/modules/rdocs/local-first-client.ts b/modules/rdocs/local-first-client.ts new file mode 100644 index 0000000..c1f3818 --- /dev/null +++ b/modules/rdocs/local-first-client.ts @@ -0,0 +1,55 @@ +/** + * rDocs Local-First Client — syncs linked Docmost documents. + */ + +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 { docsSchema, docsDocId } from './schemas'; +import type { DocsDoc, LinkedDocument } from './schemas'; + +export class DocsLocalFirstClient { + #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(docsSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('docs', 'links'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) this.#documents.open(docId, docsSchema, binary); + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + try { await this.#sync.connect(`${proto}//${location.host}/ws/${this.#space}`, this.#space); } catch {} + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = docsDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { const b = await this.#store.load(docId); doc = b ? this.#documents.open(docId, docsSchema, b) : this.#documents.open(docId, docsSchema); } + await this.#sync.subscribe([docId]); return doc ?? null; + } + + getDoc(): DocsDoc | undefined { return this.#documents.get(docsDocId(this.#space) as DocumentId); } + onChange(cb: (doc: DocsDoc) => void): () => void { return this.#sync.onChange(docsDocId(this.#space) as DocumentId, cb as (doc: any) => void); } + + linkDocument(doc: LinkedDocument): void { + this.#sync.change(docsDocId(this.#space) as DocumentId, `Link ${doc.title}`, (d) => { d.linkedDocuments[doc.id] = doc; }); + } + unlinkDocument(id: string): void { + this.#sync.change(docsDocId(this.#space) as DocumentId, `Unlink document`, (d) => { delete d.linkedDocuments[id]; }); + } + + async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); } +} diff --git a/modules/rdocs/schemas.ts b/modules/rdocs/schemas.ts new file mode 100644 index 0000000..258a4ec --- /dev/null +++ b/modules/rdocs/schemas.ts @@ -0,0 +1,36 @@ +/** + * rDocs Automerge document schemas. + * + * Syncs linked Docmost documents per space. + * Actual document content lives in the Docmost instance. + * + * DocId format: {space}:docs:links + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +export interface LinkedDocument { + id: string; + url: string; + title: string; + addedBy: string | null; + addedAt: number; +} + +export interface DocsDoc { + meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; + linkedDocuments: Record; +} + +export const docsSchema: DocSchema = { + module: 'docs', + collection: 'links', + version: 1, + init: (): DocsDoc => ({ + meta: { module: 'docs', collection: 'links', version: 1, spaceSlug: '', createdAt: Date.now() }, + linkedDocuments: {}, + }), + migrate: (doc: any) => { if (!doc.linkedDocuments) doc.linkedDocuments = {}; doc.meta.version = 1; return doc; }, +}; + +export function docsDocId(space: string) { return `${space}:docs:links` as const; } diff --git a/modules/rforum/local-first-client.ts b/modules/rforum/local-first-client.ts new file mode 100644 index 0000000..0ea8ecb --- /dev/null +++ b/modules/rforum/local-first-client.ts @@ -0,0 +1,55 @@ +/** + * rForum Local-First Client — syncs forum provisioning state. + */ + +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 { forumSchema, FORUM_DOC_ID } from './schemas'; +import type { ForumDoc, ForumInstance } from './schemas'; + +export class ForumLocalFirstClient { + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ documents: this.#documents, store: this.#store }); + this.#documents.registerSchema(forumSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('forum', 'instances'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) this.#documents.open(docId, forumSchema, binary); + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + try { await this.#sync.connect(`${proto}//${location.host}/ws/global`, 'global'); } catch {} + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = FORUM_DOC_ID as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { const b = await this.#store.load(docId); doc = b ? this.#documents.open(docId, forumSchema, b) : this.#documents.open(docId, forumSchema); } + await this.#sync.subscribe([docId]); return doc ?? null; + } + + getDoc(): ForumDoc | undefined { return this.#documents.get(FORUM_DOC_ID as DocumentId); } + onChange(cb: (doc: ForumDoc) => void): () => void { return this.#sync.onChange(FORUM_DOC_ID as DocumentId, cb as (doc: any) => void); } + + updateInstance(instance: ForumInstance): void { + this.#sync.change(FORUM_DOC_ID as DocumentId, `Update ${instance.name}`, (d) => { d.instances[instance.id] = instance; }); + } + + async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); } +} diff --git a/modules/rmaps/local-first-client.ts b/modules/rmaps/local-first-client.ts new file mode 100644 index 0000000..4fe563f --- /dev/null +++ b/modules/rmaps/local-first-client.ts @@ -0,0 +1,67 @@ +/** + * rMaps Local-First Client — persistent annotations, routes, meeting points. + */ + +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 { mapsSchema, mapsDocId } from './schemas'; +import type { MapsDoc, MapAnnotation, SavedRoute, SavedMeetingPoint } from './schemas'; + +export class MapsLocalFirstClient { + #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(mapsSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('maps', 'annotations'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) this.#documents.open(docId, mapsSchema, binary); + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + try { await this.#sync.connect(`${proto}//${location.host}/ws/${this.#space}`, this.#space); } catch {} + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = mapsDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { const b = await this.#store.load(docId); doc = b ? this.#documents.open(docId, mapsSchema, b) : this.#documents.open(docId, mapsSchema); } + await this.#sync.subscribe([docId]); return doc ?? null; + } + + getDoc(): MapsDoc | undefined { return this.#documents.get(mapsDocId(this.#space) as DocumentId); } + onChange(cb: (doc: MapsDoc) => void): () => void { return this.#sync.onChange(mapsDocId(this.#space) as DocumentId, cb as (doc: any) => void); } + + addAnnotation(annotation: MapAnnotation): void { + this.#sync.change(mapsDocId(this.#space) as DocumentId, `Add ${annotation.type}`, (d) => { d.annotations[annotation.id] = annotation; }); + } + removeAnnotation(id: string): void { + this.#sync.change(mapsDocId(this.#space) as DocumentId, `Remove annotation`, (d) => { delete d.annotations[id]; }); + } + saveRoute(route: SavedRoute): void { + this.#sync.change(mapsDocId(this.#space) as DocumentId, `Save route ${route.name}`, (d) => { d.savedRoutes[route.id] = route; }); + } + deleteRoute(id: string): void { + this.#sync.change(mapsDocId(this.#space) as DocumentId, `Delete route`, (d) => { delete d.savedRoutes[id]; }); + } + setMeetingPoint(point: SavedMeetingPoint): void { + this.#sync.change(mapsDocId(this.#space) as DocumentId, `Set meeting point`, (d) => { d.savedMeetingPoints[point.id] = point; }); + } + removeMeetingPoint(id: string): void { + this.#sync.change(mapsDocId(this.#space) as DocumentId, `Remove meeting point`, (d) => { delete d.savedMeetingPoints[id]; }); + } + + async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); } +} diff --git a/modules/rmaps/schemas.ts b/modules/rmaps/schemas.ts new file mode 100644 index 0000000..543df50 --- /dev/null +++ b/modules/rmaps/schemas.ts @@ -0,0 +1,65 @@ +/** + * rMaps Automerge document schemas. + * + * Persistent map annotations, saved routes, and meeting points. + * Ephemeral live location sharing remains via WebSocket rooms. + * + * DocId format: {space}:maps:annotations + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +export interface MapAnnotation { + id: string; + type: 'pin' | 'note' | 'area'; + lat: number; + lng: number; + label: string; + authorDid: string | null; + createdAt: number; +} + +export interface SavedRoute { + id: string; + name: string; + waypoints: { lat: number; lng: number; label?: string }[]; + authorDid: string | null; + createdAt: number; +} + +export interface SavedMeetingPoint { + id: string; + name: string; + lat: number; + lng: number; + setBy: string | null; + createdAt: number; +} + +export interface MapsDoc { + meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; + annotations: Record; + savedRoutes: Record; + savedMeetingPoints: Record; +} + +export const mapsSchema: DocSchema = { + module: 'maps', + collection: 'annotations', + version: 1, + init: (): MapsDoc => ({ + meta: { module: 'maps', collection: 'annotations', version: 1, spaceSlug: '', createdAt: Date.now() }, + annotations: {}, + savedRoutes: {}, + savedMeetingPoints: {}, + }), + migrate: (doc: any) => { + if (!doc.annotations) doc.annotations = {}; + if (!doc.savedRoutes) doc.savedRoutes = {}; + if (!doc.savedMeetingPoints) doc.savedMeetingPoints = {}; + doc.meta.version = 1; + return doc; + }, +}; + +export function mapsDocId(space: string) { return `${space}:maps:annotations` as const; } diff --git a/modules/rmeets/local-first-client.ts b/modules/rmeets/local-first-client.ts new file mode 100644 index 0000000..ffb207a --- /dev/null +++ b/modules/rmeets/local-first-client.ts @@ -0,0 +1,62 @@ +/** + * rMeets Local-First Client — syncs meeting scheduling and history. + */ + +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 { meetsSchema, meetsDocId } from './schemas'; +import type { MeetsDoc, Meeting } from './schemas'; + +export class MeetsLocalFirstClient { + #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(meetsSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('meets', 'meetings'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) this.#documents.open(docId, meetsSchema, binary); + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + try { await this.#sync.connect(`${proto}//${location.host}/ws/${this.#space}`, this.#space); } catch {} + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = meetsDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { const b = await this.#store.load(docId); doc = b ? this.#documents.open(docId, meetsSchema, b) : this.#documents.open(docId, meetsSchema); } + await this.#sync.subscribe([docId]); return doc ?? null; + } + + getDoc(): MeetsDoc | undefined { return this.#documents.get(meetsDocId(this.#space) as DocumentId); } + onChange(cb: (doc: MeetsDoc) => void): () => void { return this.#sync.onChange(meetsDocId(this.#space) as DocumentId, cb as (doc: any) => void); } + + scheduleMeeting(meeting: Meeting): void { + this.#sync.change(meetsDocId(this.#space) as DocumentId, `Schedule ${meeting.title}`, (d) => { d.meetings[meeting.id] = meeting; }); + } + cancelMeeting(id: string): void { + this.#sync.change(meetsDocId(this.#space) as DocumentId, `Cancel meeting`, (d) => { delete d.meetings[id]; }); + } + joinMeeting(meetingId: string, participantDid: string): void { + this.#sync.change(meetsDocId(this.#space) as DocumentId, `Join meeting`, (d) => { + if (d.meetings[meetingId] && !d.meetings[meetingId].participants.includes(participantDid)) { + d.meetings[meetingId].participants.push(participantDid); + } + }); + } + + async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); } +} diff --git a/modules/rmeets/schemas.ts b/modules/rmeets/schemas.ts new file mode 100644 index 0000000..0d08a48 --- /dev/null +++ b/modules/rmeets/schemas.ts @@ -0,0 +1,45 @@ +/** + * rMeets Automerge document schemas. + * + * Syncs meeting scheduling and history per space. + * Actual video calls run on the Jitsi instance. + * + * DocId format: {space}:meets:meetings + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +export interface Meeting { + id: string; + roomName: string; + title: string; + scheduledAt: number; + hostDid: string | null; + participants: string[]; + createdAt: number; +} + +export interface MeetsDoc { + meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; + meetings: Record; + meetingHistory: { id: string; roomName: string; title: string; startedAt: number; endedAt: number; participantCount: number }[]; +} + +export const meetsSchema: DocSchema = { + module: 'meets', + collection: 'meetings', + version: 1, + init: (): MeetsDoc => ({ + meta: { module: 'meets', collection: 'meetings', version: 1, spaceSlug: '', createdAt: Date.now() }, + meetings: {}, + meetingHistory: [], + }), + migrate: (doc: any) => { + if (!doc.meetings) doc.meetings = {}; + if (!doc.meetingHistory) doc.meetingHistory = []; + doc.meta.version = 1; + return doc; + }, +}; + +export function meetsDocId(space: string) { return `${space}:meets:meetings` as const; }