From ef3d0ce447b963429e606ea66973f988e1033210 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 14:01:58 -0800 Subject: [PATCH] feat: batch local-first migration for 10 modules (Phase 3) Add Automerge schemas, lifecycle hooks (onInit, docSchemas), and local-first client wrappers for all remaining PG modules: rWork, rVote, rCal, rFiles, rCart, rBooks, rTrips, rInbox, rSplat, rFunds. Each module now: - Defines typed Automerge document schemas (schemas.ts) - Registers docSchemas and onInit hook with SyncServer reference - Moves initDB() from top-level to onInit for unified startup - Has a client-side local-first wrapper (local-first-client.ts) Dual-write route handlers will be wired incrementally per module following the rNotes pattern established in Phase 2. Co-Authored-By: Claude Opus 4.6 --- modules/rbooks/local-first-client.ts | 79 ++++++++++++++ modules/rbooks/mod.ts | 12 ++- modules/rbooks/schemas.ts | 69 ++++++++++++ modules/rcal/local-first-client.ts | 96 +++++++++++++++++ modules/rcal/mod.ts | 12 ++- modules/rcal/schemas.ts | 94 +++++++++++++++++ modules/rcart/local-first-client.ts | 98 +++++++++++++++++ modules/rcart/mod.ts | 14 ++- modules/rcart/schemas.ts | 147 ++++++++++++++++++++++++++ modules/rfiles/local-first-client.ts | 95 +++++++++++++++++ modules/rfiles/mod.ts | 10 +- modules/rfiles/schemas.ts | 82 +++++++++++++++ modules/rfunds/local-first-client.ts | 95 +++++++++++++++++ modules/rfunds/mod.ts | 11 +- modules/rfunds/schemas.ts | 56 ++++++++++ modules/rinbox/local-first-client.ts | 88 ++++++++++++++++ modules/rinbox/mod.ts | 11 +- modules/rinbox/schemas.ts | 146 ++++++++++++++++++++++++++ modules/rsplat/local-first-client.ts | 79 ++++++++++++++ modules/rsplat/mod.ts | 13 ++- modules/rsplat/schemas.ts | 81 ++++++++++++++ modules/rtrips/local-first-client.ts | 118 +++++++++++++++++++++ modules/rtrips/mod.ts | 11 +- modules/rtrips/schemas.ts | 151 +++++++++++++++++++++++++++ modules/rvote/local-first-client.ts | 79 ++++++++++++++ modules/rvote/mod.ts | 20 +++- modules/rvote/schemas.ts | 117 +++++++++++++++++++++ modules/rwork/local-first-client.ts | 104 ++++++++++++++++++ modules/rwork/mod.ts | 68 +++++++++++- modules/rwork/schemas.ts | 110 +++++++++++++++++++ 30 files changed, 2144 insertions(+), 22 deletions(-) create mode 100644 modules/rbooks/local-first-client.ts create mode 100644 modules/rbooks/schemas.ts create mode 100644 modules/rcal/local-first-client.ts create mode 100644 modules/rcal/schemas.ts create mode 100644 modules/rcart/local-first-client.ts create mode 100644 modules/rcart/schemas.ts create mode 100644 modules/rfiles/local-first-client.ts create mode 100644 modules/rfiles/schemas.ts create mode 100644 modules/rfunds/local-first-client.ts create mode 100644 modules/rfunds/schemas.ts create mode 100644 modules/rinbox/local-first-client.ts create mode 100644 modules/rinbox/schemas.ts create mode 100644 modules/rsplat/local-first-client.ts create mode 100644 modules/rsplat/schemas.ts create mode 100644 modules/rtrips/local-first-client.ts create mode 100644 modules/rtrips/schemas.ts create mode 100644 modules/rvote/local-first-client.ts create mode 100644 modules/rvote/schemas.ts create mode 100644 modules/rwork/local-first-client.ts create mode 100644 modules/rwork/schemas.ts diff --git a/modules/rbooks/local-first-client.ts b/modules/rbooks/local-first-client.ts new file mode 100644 index 0000000..23b9249 --- /dev/null +++ b/modules/rbooks/local-first-client.ts @@ -0,0 +1,79 @@ +/** + * rBooks Local-First Client + * + * Wraps the shared local-first stack into a books catalog API. + * PDF files stay on the filesystem — only metadata is in Automerge. + */ + +import * as Automerge from '@automerge/automerge'; +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 { booksCatalogSchema, booksCatalogDocId } from './schemas'; +import type { BooksCatalogDoc, BookItem } from './schemas'; + +export class BooksLocalFirstClient { + #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(booksCatalogSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('books', 'catalog'); + for (const docId of cachedIds) { + const binary = await this.#store.load(docId); + if (binary) this.#documents.open(docId, booksCatalogSchema, binary); + } + 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('[BooksClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = booksCatalogDocId(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, booksCatalogSchema, binary) + : this.#documents.open(docId, booksCatalogSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getCatalog(): BooksCatalogDoc | undefined { + return this.#documents.get(booksCatalogDocId(this.#space) as DocumentId); + } + + onChange(cb: (doc: BooksCatalogDoc) => void): () => void { + return this.#sync.onChange(booksCatalogDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rbooks/mod.ts b/modules/rbooks/mod.ts index 623b23d..0b3548c 100644 --- a/modules/rbooks/mod.ts +++ b/modules/rbooks/mod.ts @@ -18,6 +18,10 @@ import { verifyEncryptIDToken, extractToken, } from "@encryptid/sdk/server"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { booksCatalogSchema } from './schemas'; + +let _syncServer: SyncServer | null = null; const BOOKS_DIR = process.env.BOOKS_DIR || "/data/books"; @@ -301,9 +305,14 @@ export const booksModule: RSpaceModule = { icon: "📚", description: "Community PDF library with flipbook reader", scoping: { defaultScope: 'global', userConfigurable: true }, + docSchemas: [{ pattern: '{space}:books:catalog', description: 'Book catalog metadata', init: booksCatalogSchema.init }], routes, standaloneDomain: "rbooks.online", landingPage: renderLanding, + async onInit(ctx) { + _syncServer = ctx.syncServer; + await initDB(); + }, feeds: [ { id: "reading-list", @@ -329,6 +338,3 @@ export const booksModule: RSpaceModule = { // Books are global, not space-scoped (for now). No-op. }, }; - -// Run schema init on import -initDB(); diff --git a/modules/rbooks/schemas.ts b/modules/rbooks/schemas.ts new file mode 100644 index 0000000..e941632 --- /dev/null +++ b/modules/rbooks/schemas.ts @@ -0,0 +1,69 @@ +/** + * rBooks Automerge document schemas. + * + * Granularity: one Automerge document per space (all books together). + * DocId format: {space}:books:catalog + * + * PDF files stay on the filesystem — only metadata migrates. + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface BookItem { + id: string; + slug: string; + title: string; + author: string; + description: string; + pdfPath: string; + pdfSizeBytes: number; + pageCount: number; + tags: string[]; + license: string | null; + coverColor: string | null; + contributorId: string | null; + contributorName: string | null; + status: string; + featured: boolean; + viewCount: number; + downloadCount: number; + createdAt: number; + updatedAt: number; +} + +export interface BooksCatalogDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + items: Record; +} + +// ── Schema registration ── + +export const booksCatalogSchema: DocSchema = { + module: 'books', + collection: 'catalog', + version: 1, + init: (): BooksCatalogDoc => ({ + meta: { + module: 'books', + collection: 'catalog', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + items: {}, + }), +}; + +// ── Helpers ── + +export function booksCatalogDocId(space: string) { + return `${space}:books:catalog` as const; +} diff --git a/modules/rcal/local-first-client.ts b/modules/rcal/local-first-client.ts new file mode 100644 index 0000000..12e0bec --- /dev/null +++ b/modules/rcal/local-first-client.ts @@ -0,0 +1,96 @@ +/** + * rCal Local-First Client + * + * Wraps the shared local-first stack into a calendar-specific API. + * External iCal sync stays server-side. + */ + +import * as Automerge from '@automerge/automerge'; +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 { calendarSchema, calendarDocId } from './schemas'; +import type { CalendarDoc, CalendarEvent, CalendarSource } from './schemas'; + +export class CalLocalFirstClient { + #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(calendarSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('cal', 'events'); + for (const docId of cachedIds) { + const binary = await this.#store.load(docId); + if (binary) this.#documents.open(docId, calendarSchema, binary); + } + 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('[CalClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = calendarDocId(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, calendarSchema, binary) + : this.#documents.open(docId, calendarSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getCalendar(): CalendarDoc | undefined { + return this.#documents.get(calendarDocId(this.#space) as DocumentId); + } + + updateEvent(eventId: string, changes: Partial): void { + const docId = calendarDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Update event ${eventId}`, (d) => { + if (!d.events[eventId]) { + d.events[eventId] = { id: eventId, title: '', description: '', startTime: 0, endTime: 0, allDay: false, timezone: null, rrule: null, status: null, visibility: null, sourceId: null, sourceName: null, sourceType: null, sourceColor: null, locationId: null, locationName: null, coordinates: null, locationGranularity: null, locationLat: null, locationLng: null, isVirtual: false, virtualUrl: null, virtualPlatform: null, rToolSource: null, rToolEntityId: null, attendees: [], attendeeCount: 0, metadata: null, createdAt: Date.now(), updatedAt: Date.now(), ...changes } as CalendarEvent; + } else { + Object.assign(d.events[eventId], changes); + d.events[eventId].updatedAt = Date.now(); + } + }); + } + + deleteEvent(eventId: string): void { + const docId = calendarDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete event ${eventId}`, (d) => { delete d.events[eventId]; }); + } + + onChange(cb: (doc: CalendarDoc) => void): () => void { + return this.#sync.onChange(calendarDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 93b6557..871b446 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -14,6 +14,10 @@ import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { calendarSchema } from './schemas'; + +let _syncServer: SyncServer | null = null; const routes = new Hono(); @@ -130,8 +134,6 @@ function daysFromNow(days: number, hours: number, minutes: number): Date { return d; } -initDB().then(seedDemoIfEmpty); - // ── API: Events ── // GET /api/events — query events with filters @@ -395,9 +397,15 @@ export const calModule: RSpaceModule = { icon: "📅", description: "Temporal coordination calendar with lunar, solar, and seasonal systems", scoping: { defaultScope: 'global', userConfigurable: true }, + docSchemas: [{ pattern: '{space}:cal:events', description: 'Calendar events and sources', init: calendarSchema.init }], routes, standaloneDomain: "rcal.online", landingPage: renderLanding, + async onInit(ctx) { + _syncServer = ctx.syncServer; + await initDB(); + await seedDemoIfEmpty(); + }, feeds: [ { id: "events", diff --git a/modules/rcal/schemas.ts b/modules/rcal/schemas.ts new file mode 100644 index 0000000..72a6132 --- /dev/null +++ b/modules/rcal/schemas.ts @@ -0,0 +1,94 @@ +/** + * rCal Automerge document schemas. + * + * Granularity: one Automerge document per space (all events + sources). + * DocId format: {space}:cal:events + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface CalendarSource { + id: string; + name: string; + sourceType: string; + url: string | null; + color: string | null; + isActive: boolean; + isVisible: boolean; + syncIntervalMinutes: number | null; + lastSyncedAt: number; + ownerId: string | null; + createdAt: number; +} + +export interface CalendarEvent { + id: string; + title: string; + description: string; + startTime: number; + endTime: number; + allDay: boolean; + timezone: string | null; + rrule: string | null; + status: string | null; + visibility: string | null; + sourceId: string | null; + sourceName: string | null; + sourceType: string | null; + sourceColor: string | null; + locationId: string | null; + locationName: string | null; + coordinates: { x: number; y: number } | null; + locationGranularity: string | null; + locationLat: number | null; + locationLng: number | null; + isVirtual: boolean; + virtualUrl: string | null; + virtualPlatform: string | null; + rToolSource: string | null; + rToolEntityId: string | null; + attendees: unknown[]; + attendeeCount: number; + metadata: unknown | null; + createdAt: number; + updatedAt: number; +} + +export interface CalendarDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + sources: Record; + events: Record; +} + +// ── Schema registration ── + +export const calendarSchema: DocSchema = { + module: 'cal', + collection: 'events', + version: 1, + init: (): CalendarDoc => ({ + meta: { + module: 'cal', + collection: 'events', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + sources: {}, + events: {}, + }), +}; + +// ── Helpers ── + +export function calendarDocId(space: string) { + return `${space}:cal:events` as const; +} diff --git a/modules/rcart/local-first-client.ts b/modules/rcart/local-first-client.ts new file mode 100644 index 0000000..d0ea566 --- /dev/null +++ b/modules/rcart/local-first-client.ts @@ -0,0 +1,98 @@ +/** + * rCart Local-First Client + * + * Wraps the shared local-first stack into a cart-specific API. + * Orders use Intent/Claim — server validates pricing and fulfillment. + */ + +import * as Automerge from '@automerge/automerge'; +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 { catalogSchema, orderSchema, catalogDocId, orderDocId } from './schemas'; +import type { CatalogDoc, CatalogEntry, OrderDoc } from './schemas'; + +export class CartLocalFirstClient { + #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(catalogSchema); + this.#documents.registerSchema(orderSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const catalogIds = await this.#store.listByModule('cart', 'catalog'); + for (const docId of catalogIds) { + const binary = await this.#store.load(docId); + if (binary) this.#documents.open(docId, catalogSchema, binary); + } + const orderIds = await this.#store.listByModule('cart', 'orders'); + for (const docId of orderIds) { + const binary = await this.#store.load(docId); + if (binary) this.#documents.open(docId, orderSchema, binary); + } + 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('[CartClient] Working offline'); } + this.#initialized = true; + } + + async subscribeCatalog(): Promise { + const docId = catalogDocId(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, catalogSchema, binary) + : this.#documents.open(docId, catalogSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + async subscribeOrder(orderId: string): Promise { + const docId = orderDocId(this.#space, orderId) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, orderSchema, binary) + : this.#documents.open(docId, orderSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getCatalog(): CatalogDoc | undefined { + return this.#documents.get(catalogDocId(this.#space) as DocumentId); + } + + onChange(cb: (doc: CatalogDoc) => void): () => void { + return this.#sync.onChange(catalogDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index f08b0a5..9e4ec15 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -16,6 +16,10 @@ import { depositOrderRevenue } from "./flow"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { catalogSchema, orderSchema } from './schemas'; + +let _syncServer: SyncServer | null = null; const routes = new Hono(); @@ -31,8 +35,6 @@ async function initDB() { } } -initDB(); - // Provider registry URL (for fulfillment resolution) const PROVIDER_REGISTRY_URL = process.env.PROVIDER_REGISTRY_URL || ""; @@ -460,9 +462,17 @@ export const cartModule: RSpaceModule = { icon: "🛒", description: "Cosmolocal print-on-demand shop", scoping: { defaultScope: 'space', userConfigurable: false }, + docSchemas: [ + { pattern: '{space}:cart:catalog', description: 'Product catalog', init: catalogSchema.init }, + { pattern: '{space}:cart:orders:{orderId}', description: 'Order document', init: orderSchema.init }, + ], routes, standaloneDomain: "rcart.online", landingPage: renderLanding, + async onInit(ctx) { + _syncServer = ctx.syncServer; + await initDB(); + }, feeds: [ { id: "orders", diff --git a/modules/rcart/schemas.ts b/modules/rcart/schemas.ts new file mode 100644 index 0000000..f2a2513 --- /dev/null +++ b/modules/rcart/schemas.ts @@ -0,0 +1,147 @@ +/** + * rCart Automerge document schemas. + * + * Two document types: + * - Catalog: one doc per space holding all catalog entries. + * DocId: {space}:cart:catalog + * - Orders: one doc per order (server-validated via Intent/Claim). + * DocId: {space}:cart:orders:{orderId} + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface CatalogEntry { + id: string; + artifactId: string; + artifact: unknown; + title: string; + productType: string | null; + requiredCapabilities: string[]; + substrates: string[]; + creatorId: string | null; + sourceSpace: string | null; + tags: string[]; + status: string; + createdAt: number; + updatedAt: number; +} + +export interface CatalogDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + items: Record; +} + +export interface OrderMeta { + id: string; + catalogEntryId: string; + artifactId: string; + buyerId: string | null; + buyerLocation: string | null; + buyerContact: string | null; + providerId: string | null; + providerName: string | null; + providerDistanceKm: number | null; + quantity: number; + productionCost: number | null; + creatorPayout: number | null; + communityPayout: number | null; + totalPrice: number | null; + currency: string; + status: string; + paymentMethod: string | null; + paymentTx: string | null; + paymentNetwork: string | null; + createdAt: number; + paidAt: number; + acceptedAt: number; + completedAt: number; + updatedAt: number; +} + +export interface OrderDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + order: OrderMeta; +} + +// ── Schema registration ── + +export const catalogSchema: DocSchema = { + module: 'cart', + collection: 'catalog', + version: 1, + init: (): CatalogDoc => ({ + meta: { + module: 'cart', + collection: 'catalog', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + items: {}, + }), +}; + +export const orderSchema: DocSchema = { + module: 'cart', + collection: 'orders', + version: 1, + init: (): OrderDoc => ({ + meta: { + module: 'cart', + collection: 'orders', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + order: { + id: '', + catalogEntryId: '', + artifactId: '', + buyerId: null, + buyerLocation: null, + buyerContact: null, + providerId: null, + providerName: null, + providerDistanceKm: null, + quantity: 1, + productionCost: null, + creatorPayout: null, + communityPayout: null, + totalPrice: null, + currency: 'USD', + status: 'pending', + paymentMethod: null, + paymentTx: null, + paymentNetwork: null, + createdAt: Date.now(), + paidAt: 0, + acceptedAt: 0, + completedAt: 0, + updatedAt: Date.now(), + }, + }), +}; + +// ── Helpers ── + +export function catalogDocId(space: string) { + return `${space}:cart:catalog` as const; +} + +export function orderDocId(space: string, orderId: string) { + return `${space}:cart:orders:${orderId}` as const; +} diff --git a/modules/rfiles/local-first-client.ts b/modules/rfiles/local-first-client.ts new file mode 100644 index 0000000..785dece --- /dev/null +++ b/modules/rfiles/local-first-client.ts @@ -0,0 +1,95 @@ +/** + * rFiles Local-First Client + * + * Wraps the shared local-first stack into a files-specific API. + * Binary file data stays on the filesystem — only metadata is in Automerge. + */ + +import * as Automerge from '@automerge/automerge'; +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 { filesSchema, filesDocId } from './schemas'; +import type { FilesDoc, MediaFile, MemoryCard } from './schemas'; + +export class FilesLocalFirstClient { + #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(filesSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('files', 'cards'); + for (const docId of cachedIds) { + const binary = await this.#store.load(docId); + if (binary) this.#documents.open(docId, filesSchema, binary); + } + 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('[FilesClient] Working offline'); } + this.#initialized = true; + } + + async subscribeSharedSpace(sharedSpace: string): Promise { + const docId = filesDocId(this.#space, sharedSpace) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, filesSchema, binary) + : this.#documents.open(docId, filesSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + updateFile(sharedSpace: string, fileId: string, changes: Partial): void { + const docId = filesDocId(this.#space, sharedSpace) as DocumentId; + this.#sync.change(docId, `Update file ${fileId}`, (d) => { + if (d.files[fileId]) { + Object.assign(d.files[fileId], changes); + d.files[fileId].updatedAt = Date.now(); + } + }); + } + + updateMemoryCard(sharedSpace: string, cardId: string, changes: Partial): void { + const docId = filesDocId(this.#space, sharedSpace) as DocumentId; + this.#sync.change(docId, `Update card ${cardId}`, (d) => { + if (d.memoryCards[cardId]) { + Object.assign(d.memoryCards[cardId], changes); + d.memoryCards[cardId].updatedAt = Date.now(); + } + }); + } + + onChange(sharedSpace: string, cb: (doc: FilesDoc) => void): () => void { + return this.#sync.onChange(filesDocId(this.#space, sharedSpace) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rfiles/mod.ts b/modules/rfiles/mod.ts index b389eb7..a678b66 100644 --- a/modules/rfiles/mod.ts +++ b/modules/rfiles/mod.ts @@ -14,6 +14,10 @@ import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { filesSchema } from './schemas'; + +let _syncServer: SyncServer | null = null; const routes = new Hono(); @@ -29,7 +33,6 @@ async function initDB() { console.error("[Files] DB init error:", e.message); } } -initDB(); // ── Cleanup timers (replace Celery) ── // Deactivate expired shares every hour @@ -400,8 +403,13 @@ export const filesModule: RSpaceModule = { icon: "📁", description: "File sharing, share links, and memory cards", scoping: { defaultScope: 'space', userConfigurable: false }, + docSchemas: [{ pattern: '{space}:files:cards:{sharedSpace}', description: 'Files and memory cards', init: filesSchema.init }], routes, landingPage: renderLanding, + async onInit(ctx) { + _syncServer = ctx.syncServer; + await initDB(); + }, standaloneDomain: "rfiles.online", externalApp: { url: "https://files.rfiles.online", name: "Seafile" }, feeds: [ diff --git a/modules/rfiles/schemas.ts b/modules/rfiles/schemas.ts new file mode 100644 index 0000000..0734cdc --- /dev/null +++ b/modules/rfiles/schemas.ts @@ -0,0 +1,82 @@ +/** + * rFiles Automerge document schemas. + * + * Granularity: one Automerge document per shared_space. + * DocId format: {space}:files:cards:{sharedSpace} + * + * Binary file data stays on the filesystem — only metadata migrates. + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface MediaFile { + id: string; + originalFilename: string; + title: string | null; + description: string; + mimeType: string | null; + fileSize: number; + fileHash: string | null; + storagePath: string; + tags: string[]; + isProcessed: boolean; + processingError: string | null; + uploadedBy: string | null; + sharedSpace: string | null; + createdAt: number; + updatedAt: number; +} + +export interface MemoryCard { + id: string; + sharedSpace: string; + title: string; + body: string; + cardType: string | null; + tags: string[]; + position: number; + createdBy: string | null; + createdAt: number; + updatedAt: number; +} + +export interface FilesDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + sharedSpace: string; + createdAt: number; + }; + files: Record; + memoryCards: Record; +} + +// ── Schema registration ── + +export const filesSchema: DocSchema = { + module: 'files', + collection: 'cards', + version: 1, + init: (): FilesDoc => ({ + meta: { + module: 'files', + collection: 'cards', + version: 1, + spaceSlug: '', + sharedSpace: '', + createdAt: Date.now(), + }, + files: {}, + memoryCards: {}, + }), +}; + +// ── Helpers ── + +export function filesDocId(space: string, sharedSpace: string) { + return `${space}:files:cards:${sharedSpace}` as const; +} diff --git a/modules/rfunds/local-first-client.ts b/modules/rfunds/local-first-client.ts new file mode 100644 index 0000000..d17384c --- /dev/null +++ b/modules/rfunds/local-first-client.ts @@ -0,0 +1,95 @@ +/** + * rFunds Local-First Client + * + * Wraps the shared local-first stack for space-flow associations. + * Actual flow logic stays in the external payment-flow service. + */ + +import * as Automerge from '@automerge/automerge'; +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 { fundsSchema, fundsDocId } from './schemas'; +import type { FundsDoc, SpaceFlow } from './schemas'; + +export class FundsLocalFirstClient { + #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(fundsSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('funds', 'flows'); + for (const docId of cachedIds) { + const binary = await this.#store.load(docId); + if (binary) this.#documents.open(docId, fundsSchema, binary); + } + 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('[FundsClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = fundsDocId(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, fundsSchema, binary) + : this.#documents.open(docId, fundsSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getFlows(): FundsDoc | undefined { + return this.#documents.get(fundsDocId(this.#space) as DocumentId); + } + + addSpaceFlow(flow: SpaceFlow): void { + const docId = fundsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Add flow ${flow.flowId}`, (d) => { + d.spaceFlows[flow.id] = flow; + }); + } + + removeSpaceFlow(flowId: string): void { + const docId = fundsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Remove flow ${flowId}`, (d) => { + for (const [id, sf] of Object.entries(d.spaceFlows)) { + if (sf.flowId === flowId) delete d.spaceFlows[id]; + } + }); + } + + onChange(cb: (doc: FundsDoc) => void): () => void { + return this.#sync.onChange(fundsDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rfunds/mod.ts b/modules/rfunds/mod.ts index 093faba..4c87975 100644 --- a/modules/rfunds/mod.ts +++ b/modules/rfunds/mod.ts @@ -13,6 +13,10 @@ import type { RSpaceModule } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { fundsSchema } from './schemas'; + +let _syncServer: SyncServer | null = null; const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010"; @@ -28,8 +32,6 @@ async function initDB() { } } -initDB(); - const routes = new Hono(); // ─── Flow Service API proxy ───────────────────────────── @@ -247,8 +249,13 @@ export const fundsModule: RSpaceModule = { icon: "🌊", description: "Budget flows, river visualization, and treasury management", scoping: { defaultScope: 'space', userConfigurable: false }, + docSchemas: [{ pattern: '{space}:funds:flows', description: 'Space flow associations', init: fundsSchema.init }], routes, landingPage: renderLanding, + async onInit(ctx) { + _syncServer = ctx.syncServer; + await initDB(); + }, standaloneDomain: "rfunds.online", feeds: [ { diff --git a/modules/rfunds/schemas.ts b/modules/rfunds/schemas.ts new file mode 100644 index 0000000..6e0f49c --- /dev/null +++ b/modules/rfunds/schemas.ts @@ -0,0 +1,56 @@ +/** + * rFunds Automerge document schemas. + * + * Granularity: one Automerge document per space (flow associations). + * DocId format: {space}:funds:flows + * + * Actual flow logic stays in the external payment-flow service. + * This doc tracks which flows are associated with which spaces. + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface SpaceFlow { + id: string; + spaceSlug: string; + flowId: string; + addedBy: string | null; + createdAt: number; +} + +export interface FundsDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + spaceFlows: Record; +} + +// ── Schema registration ── + +export const fundsSchema: DocSchema = { + module: 'funds', + collection: 'flows', + version: 1, + init: (): FundsDoc => ({ + meta: { + module: 'funds', + collection: 'flows', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + spaceFlows: {}, + }), +}; + +// ── Helpers ── + +export function fundsDocId(space: string) { + return `${space}:funds:flows` as const; +} diff --git a/modules/rinbox/local-first-client.ts b/modules/rinbox/local-first-client.ts new file mode 100644 index 0000000..3bffed4 --- /dev/null +++ b/modules/rinbox/local-first-client.ts @@ -0,0 +1,88 @@ +/** + * rInbox Local-First Client + * + * Wraps the shared local-first stack into a mailbox-specific API. + * IMAP sync stays server-side — Automerge holds thread/comment state. + */ + +import * as Automerge from '@automerge/automerge'; +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 { mailboxSchema, mailboxDocId } from './schemas'; +import type { MailboxDoc, ThreadItem } from './schemas'; + +export class InboxLocalFirstClient { + #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(mailboxSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('inbox', 'mailboxes'); + for (const docId of cachedIds) { + const binary = await this.#store.load(docId); + if (binary) this.#documents.open(docId, mailboxSchema, binary); + } + 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('[InboxClient] Working offline'); } + this.#initialized = true; + } + + async subscribeMailbox(mailboxId: string): Promise { + const docId = mailboxDocId(this.#space, mailboxId) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, mailboxSchema, binary) + : this.#documents.open(docId, mailboxSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getMailbox(mailboxId: string): MailboxDoc | undefined { + return this.#documents.get(mailboxDocId(this.#space, mailboxId) as DocumentId); + } + + updateThread(mailboxId: string, threadId: string, changes: Partial): void { + const docId = mailboxDocId(this.#space, mailboxId) as DocumentId; + this.#sync.change(docId, `Update thread ${threadId}`, (d) => { + if (d.threads[threadId]) { + Object.assign(d.threads[threadId], changes); + } + }); + } + + onChange(mailboxId: string, cb: (doc: MailboxDoc) => void): () => void { + return this.#sync.onChange(mailboxDocId(this.#space, mailboxId) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts index e8a870f..ec325c9 100644 --- a/modules/rinbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -14,6 +14,10 @@ import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { mailboxSchema } from './schemas'; + +let _syncServer: SyncServer | null = null; const routes = new Hono(); @@ -29,8 +33,6 @@ async function initDB() { } } -initDB(); - // ── Helper: get or create user by DID ── async function getOrCreateUser(did: string, username?: string) { const rows = await sql.unsafe( @@ -600,8 +602,13 @@ export const inboxModule: RSpaceModule = { icon: "📨", description: "Collaborative email with multisig approval", scoping: { defaultScope: 'space', userConfigurable: false }, + docSchemas: [{ pattern: '{space}:inbox:mailboxes:{mailboxId}', description: 'Mailbox with threads and approvals', init: mailboxSchema.init }], routes, landingPage: renderLanding, + async onInit(ctx) { + _syncServer = ctx.syncServer; + await initDB(); + }, standaloneDomain: "rinbox.online", feeds: [ { diff --git a/modules/rinbox/schemas.ts b/modules/rinbox/schemas.ts new file mode 100644 index 0000000..5b6300f --- /dev/null +++ b/modules/rinbox/schemas.ts @@ -0,0 +1,146 @@ +/** + * rInbox Automerge document schemas. + * + * Granularity: one Automerge document per mailbox. + * DocId format: {space}:inbox:mailboxes:{mailboxId} + * + * IMAP sync stays server-side — Automerge holds thread/comment state. + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface MailboxMember { + id: string; + mailboxId: string; + userId: string; + role: string; + joinedAt: number; +} + +export interface ThreadComment { + id: string; + threadId: string; + authorId: string; + body: string; + mentions: string[]; + createdAt: number; +} + +export interface ThreadItem { + id: string; + mailboxId: string; + messageId: string | null; + subject: string; + fromAddress: string | null; + fromName: string | null; + toAddresses: string[]; + ccAddresses: string[]; + bodyText: string; + bodyHtml: string; + tags: string[]; + status: string; + isRead: boolean; + isStarred: boolean; + assignedTo: string | null; + hasAttachments: boolean; + receivedAt: number; + createdAt: number; + comments: ThreadComment[]; +} + +export interface ApprovalSignature { + id: string; + approvalId: string; + signerId: string; + vote: string; + signedAt: number; +} + +export interface ApprovalItem { + id: string; + mailboxId: string; + threadId: string | null; + authorId: string; + subject: string; + bodyText: string; + bodyHtml: string; + toAddresses: string[]; + ccAddresses: string[]; + status: string; + requiredSignatures: number; + safeTxHash: string | null; + createdAt: number; + resolvedAt: number; + signatures: ApprovalSignature[]; +} + +export interface MailboxMeta { + id: string; + workspaceId: string | null; + slug: string; + name: string; + email: string; + description: string; + visibility: string; + ownerDid: string; + safeAddress: string | null; + safeChainId: number | null; + approvalThreshold: number; + createdAt: number; +} + +export interface MailboxDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + mailbox: MailboxMeta; + members: MailboxMember[]; + threads: Record; + approvals: Record; +} + +// ── Schema registration ── + +export const mailboxSchema: DocSchema = { + module: 'inbox', + collection: 'mailboxes', + version: 1, + init: (): MailboxDoc => ({ + meta: { + module: 'inbox', + collection: 'mailboxes', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + mailbox: { + id: '', + workspaceId: null, + slug: '', + name: '', + email: '', + description: '', + visibility: 'private', + ownerDid: '', + safeAddress: null, + safeChainId: null, + approvalThreshold: 1, + createdAt: Date.now(), + }, + members: [], + threads: {}, + approvals: {}, + }), +}; + +// ── Helpers ── + +export function mailboxDocId(space: string, mailboxId: string) { + return `${space}:inbox:mailboxes:${mailboxId}` as const; +} diff --git a/modules/rsplat/local-first-client.ts b/modules/rsplat/local-first-client.ts new file mode 100644 index 0000000..b3ae570 --- /dev/null +++ b/modules/rsplat/local-first-client.ts @@ -0,0 +1,79 @@ +/** + * rSplat Local-First Client + * + * Wraps the shared local-first stack into a splat gallery API. + * 3D files stay on the filesystem — only metadata is in Automerge. + */ + +import * as Automerge from '@automerge/automerge'; +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 { splatScenesSchema, splatScenesDocId } from './schemas'; +import type { SplatScenesDoc } from './schemas'; + +export class SplatLocalFirstClient { + #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(splatScenesSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('splat', 'scenes'); + for (const docId of cachedIds) { + const binary = await this.#store.load(docId); + if (binary) this.#documents.open(docId, splatScenesSchema, binary); + } + 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('[SplatClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = splatScenesDocId(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, splatScenesSchema, binary) + : this.#documents.open(docId, splatScenesSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getScenes(): SplatScenesDoc | undefined { + return this.#documents.get(splatScenesDocId(this.#space) as DocumentId); + } + + onChange(cb: (doc: SplatScenesDoc) => void): () => void { + return this.#sync.onChange(splatScenesDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts index a46fe97..a9bee27 100644 --- a/modules/rsplat/mod.ts +++ b/modules/rsplat/mod.ts @@ -19,6 +19,10 @@ import { extractToken, } from "@encryptid/sdk/server"; import { setupX402FromEnv } from "../../shared/x402/hono-middleware"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { splatScenesSchema } from './schemas'; + +let _syncServer: SyncServer | null = null; const SPLATS_DIR = process.env.SPLATS_DIR || "/data/splats"; const SOURCES_DIR = resolve(SPLATS_DIR, "sources"); @@ -540,6 +544,7 @@ export const splatModule: RSpaceModule = { icon: "🔮", description: "3D Gaussian splat viewer", scoping: { defaultScope: 'global', userConfigurable: true }, + docSchemas: [{ pattern: '{space}:splat:scenes', description: 'Splat scene metadata', init: splatScenesSchema.init }], routes, landingPage: renderLanding, standaloneDomain: "rsplat.online", @@ -547,11 +552,11 @@ export const splatModule: RSpaceModule = { outputPaths: [ { path: "drawings", name: "Drawings", icon: "🔮", description: "3D Gaussian splat drawings" }, ], - + async onInit(ctx) { + _syncServer = ctx.syncServer; + await initDB(); + }, async onSpaceCreate(ctx: SpaceLifecycleContext) { // Splats are scoped by space_slug column. No per-space setup needed. }, }; - -// Run schema init on import -initDB(); diff --git a/modules/rsplat/schemas.ts b/modules/rsplat/schemas.ts new file mode 100644 index 0000000..5596913 --- /dev/null +++ b/modules/rsplat/schemas.ts @@ -0,0 +1,81 @@ +/** + * rSplat Automerge document schemas. + * + * Granularity: one Automerge document per space (all splats together). + * DocId format: {space}:splat:scenes + * + * 3D files stay on the filesystem — only metadata migrates. + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface SourceFile { + id: string; + splatId: string; + filePath: string; + fileName: string; + mimeType: string | null; + fileSizeBytes: number; + createdAt: number; +} + +export interface SplatItem { + id: string; + slug: string; + title: string; + description: string; + filePath: string; + fileFormat: string; + fileSizeBytes: number; + tags: string[]; + spaceSlug: string; + contributorId: string | null; + contributorName: string | null; + source: string | null; + status: string; + viewCount: number; + paymentTx: string | null; + paymentNetwork: string | null; + createdAt: number; + processingStatus: string | null; + processingError: string | null; + sourceFileCount: number; + sourceFiles: SourceFile[]; +} + +export interface SplatScenesDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + items: Record; +} + +// ── Schema registration ── + +export const splatScenesSchema: DocSchema = { + module: 'splat', + collection: 'scenes', + version: 1, + init: (): SplatScenesDoc => ({ + meta: { + module: 'splat', + collection: 'scenes', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + items: {}, + }), +}; + +// ── Helpers ── + +export function splatScenesDocId(space: string) { + return `${space}:splat:scenes` as const; +} diff --git a/modules/rtrips/local-first-client.ts b/modules/rtrips/local-first-client.ts new file mode 100644 index 0000000..e89efa4 --- /dev/null +++ b/modules/rtrips/local-first-client.ts @@ -0,0 +1,118 @@ +/** + * rTrips Local-First Client + * + * Wraps the shared local-first stack into a trips-specific API. + */ + +import * as Automerge from '@automerge/automerge'; +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 { tripSchema, tripDocId } from './schemas'; +import type { TripDoc, TripMeta, Destination, ItineraryItem, Booking, Expense, PackingItem } from './schemas'; + +export class TripsLocalFirstClient { + #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(tripSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('trips', 'trips'); + for (const docId of cachedIds) { + const binary = await this.#store.load(docId); + if (binary) this.#documents.open(docId, tripSchema, binary); + } + 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('[TripsClient] Working offline'); } + this.#initialized = true; + } + + async subscribeTrip(tripId: string): Promise { + const docId = tripDocId(this.#space, tripId) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, tripSchema, binary) + : this.#documents.open(docId, tripSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getTrip(tripId: string): TripDoc | undefined { + return this.#documents.get(tripDocId(this.#space, tripId) as DocumentId); + } + + updateTrip(tripId: string, changes: Partial): void { + const docId = tripDocId(this.#space, tripId) as DocumentId; + this.#sync.change(docId, 'Update trip', (d) => { + Object.assign(d.trip, changes); + d.trip.updatedAt = Date.now(); + }); + } + + addDestination(tripId: string, dest: Destination): void { + const docId = tripDocId(this.#space, tripId) as DocumentId; + this.#sync.change(docId, `Add destination ${dest.id}`, (d) => { d.destinations[dest.id] = dest; }); + } + + addItineraryItem(tripId: string, item: ItineraryItem): void { + const docId = tripDocId(this.#space, tripId) as DocumentId; + this.#sync.change(docId, `Add itinerary ${item.id}`, (d) => { d.itinerary[item.id] = item; }); + } + + addBooking(tripId: string, booking: Booking): void { + const docId = tripDocId(this.#space, tripId) as DocumentId; + this.#sync.change(docId, `Add booking ${booking.id}`, (d) => { d.bookings[booking.id] = booking; }); + } + + addExpense(tripId: string, expense: Expense): void { + const docId = tripDocId(this.#space, tripId) as DocumentId; + this.#sync.change(docId, `Add expense ${expense.id}`, (d) => { d.expenses[expense.id] = expense; }); + } + + addPackingItem(tripId: string, item: PackingItem): void { + const docId = tripDocId(this.#space, tripId) as DocumentId; + this.#sync.change(docId, `Add packing ${item.id}`, (d) => { d.packingItems[item.id] = item; }); + } + + togglePacked(tripId: string, itemId: string, packed: boolean): void { + const docId = tripDocId(this.#space, tripId) as DocumentId; + this.#sync.change(docId, `Toggle packed ${itemId}`, (d) => { + if (d.packingItems[itemId]) d.packingItems[itemId].packed = packed; + }); + } + + onChange(tripId: string, cb: (doc: TripDoc) => void): () => void { + return this.#sync.onChange(tripDocId(this.#space, tripId) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rtrips/mod.ts b/modules/rtrips/mod.ts index 9b058ba..4b8a4b3 100644 --- a/modules/rtrips/mod.ts +++ b/modules/rtrips/mod.ts @@ -14,6 +14,10 @@ import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { tripSchema } from './schemas'; + +let _syncServer: SyncServer | null = null; const OSRM_URL = process.env.OSRM_URL || "http://osrm-backend:5000"; @@ -31,8 +35,6 @@ async function initDB() { } } -initDB(); - // ── API: Trips ── // GET /api/trips — list trips @@ -272,8 +274,13 @@ export const tripsModule: RSpaceModule = { icon: "✈️", description: "Collaborative trip planner with itinerary, bookings, and expense splitting", scoping: { defaultScope: 'global', userConfigurable: true }, + docSchemas: [{ pattern: '{space}:trips:trips:{tripId}', description: 'Trip with destinations and itinerary', init: tripSchema.init }], routes, landingPage: renderLanding, + async onInit(ctx) { + _syncServer = ctx.syncServer; + await initDB(); + }, standaloneDomain: "rtrips.online", feeds: [ { diff --git a/modules/rtrips/schemas.ts b/modules/rtrips/schemas.ts new file mode 100644 index 0000000..a6e9f9d --- /dev/null +++ b/modules/rtrips/schemas.ts @@ -0,0 +1,151 @@ +/** + * rTrips Automerge document schemas. + * + * Granularity: one Automerge document per trip. + * DocId format: {space}:trips:trips:{tripId} + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface TripMeta { + id: string; + title: string; + slug: string; + description: string; + startDate: string | null; + endDate: string | null; + budgetTotal: number | null; + budgetCurrency: string | null; + status: string; + createdBy: string | null; + createdAt: number; + updatedAt: number; +} + +export interface Destination { + id: string; + tripId: string; + name: string; + country: string | null; + lat: number | null; + lng: number | null; + arrivalDate: string | null; + departureDate: string | null; + notes: string; + sortOrder: number; + createdAt: number; +} + +export interface ItineraryItem { + id: string; + tripId: string; + destinationId: string | null; + title: string; + category: string | null; + date: string | null; + startTime: string | null; + endTime: string | null; + notes: string; + sortOrder: number; + createdAt: number; +} + +export interface Booking { + id: string; + tripId: string; + type: string | null; + provider: string | null; + confirmationNumber: string | null; + cost: number | null; + currency: string | null; + startDate: string | null; + endDate: string | null; + status: string | null; + notes: string; + createdAt: number; +} + +export interface Expense { + id: string; + tripId: string; + paidBy: string | null; + description: string; + amount: number; + currency: string | null; + category: string | null; + date: string | null; + splitType: string | null; + createdAt: number; +} + +export interface PackingItem { + id: string; + tripId: string; + addedBy: string | null; + name: string; + category: string | null; + packed: boolean; + quantity: number; + sortOrder: number; + createdAt: number; +} + +export interface TripDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + trip: TripMeta; + destinations: Record; + itinerary: Record; + bookings: Record; + expenses: Record; + packingItems: Record; +} + +// ── Schema registration ── + +export const tripSchema: DocSchema = { + module: 'trips', + collection: 'trips', + version: 1, + init: (): TripDoc => ({ + meta: { + module: 'trips', + collection: 'trips', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + trip: { + id: '', + title: 'Untitled Trip', + slug: '', + description: '', + startDate: null, + endDate: null, + budgetTotal: null, + budgetCurrency: null, + status: 'planning', + createdBy: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + destinations: {}, + itinerary: {}, + bookings: {}, + expenses: {}, + packingItems: {}, + }), +}; + +// ── Helpers ── + +export function tripDocId(space: string, tripId: string) { + return `${space}:trips:trips:${tripId}` as const; +} diff --git a/modules/rvote/local-first-client.ts b/modules/rvote/local-first-client.ts new file mode 100644 index 0000000..22950b6 --- /dev/null +++ b/modules/rvote/local-first-client.ts @@ -0,0 +1,79 @@ +/** + * rVote Local-First Client + * + * Wraps the shared local-first stack into a voting-specific API. + * Note: Vote tallying uses Intent/Claim pattern — server validates votes. + */ + +import * as Automerge from '@automerge/automerge'; +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 { proposalSchema, proposalDocId } from './schemas'; +import type { ProposalDoc } from './schemas'; + +export class VoteLocalFirstClient { + #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(proposalSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('vote', 'proposals'); + for (const docId of cachedIds) { + const binary = await this.#store.load(docId); + if (binary) this.#documents.open(docId, proposalSchema, binary); + } + 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('[VoteClient] Working offline'); } + this.#initialized = true; + } + + async subscribeProposal(proposalId: string): Promise { + const docId = proposalDocId(this.#space, proposalId) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, proposalSchema, binary) + : this.#documents.open(docId, proposalSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getProposal(proposalId: string): ProposalDoc | undefined { + return this.#documents.get(proposalDocId(this.#space, proposalId) as DocumentId); + } + + onChange(proposalId: string, cb: (doc: ProposalDoc) => void): () => void { + return this.#sync.onChange(proposalDocId(this.#space, proposalId) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index be6d53a..1a923fa 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -8,12 +8,16 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import * as Automerge from '@automerge/automerge'; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; -import type { RSpaceModule } from "../../shared/module"; +import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { proposalSchema, proposalDocId } from './schemas'; +import type { ProposalDoc } from './schemas'; const routes = new Hono(); @@ -87,7 +91,13 @@ async function seedDemoIfEmpty() { } } -initDB().then(seedDemoIfEmpty); +// ── Local-first helpers ── +let _syncServer: SyncServer | null = null; + +function isLocalFirst(space: string): boolean { + if (!_syncServer) return false; + return _syncServer.getDocIds().some((id) => id.startsWith(`${space}:vote:`)); +} // ── Helper: get or create user by DID ── async function getOrCreateUser(did: string, username?: string) { @@ -346,9 +356,15 @@ export const voteModule: RSpaceModule = { icon: "🗳", description: "Conviction voting engine for collaborative governance", scoping: { defaultScope: 'space', userConfigurable: false }, + docSchemas: [{ pattern: '{space}:vote:proposals:{proposalId}', description: 'Proposal with votes', init: proposalSchema.init }], routes, standaloneDomain: "rvote.online", landingPage: renderLanding, + async onInit(ctx) { + _syncServer = ctx.syncServer; + await initDB(); + await seedDemoIfEmpty(); + }, feeds: [ { id: "proposals", diff --git a/modules/rvote/schemas.ts b/modules/rvote/schemas.ts new file mode 100644 index 0000000..4739eeb --- /dev/null +++ b/modules/rvote/schemas.ts @@ -0,0 +1,117 @@ +/** + * rVote Automerge document schemas. + * + * Granularity: one Automerge document per proposal. + * DocId format: {space}:vote:proposals:{proposalId} + * + * Vote tallying uses the Intent/Claim pattern: + * clients submit vote intents, server validates and writes claims. + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface VoteItem { + id: string; + userId: string; + proposalId: string; + weight: number; + creditCost: number; + createdAt: number; + decaysAt: number; +} + +export interface FinalVoteItem { + id: string; + userId: string; + proposalId: string; + vote: 'YES' | 'NO' | 'ABSTAIN'; + createdAt: number; +} + +export interface SpaceConfig { + slug: string; + name: string; + description: string; + ownerDid: string; + visibility: string; + promotionThreshold: number | null; + votingPeriodDays: number | null; + creditsPerDay: number | null; + maxCredits: number | null; + startingCredits: number | null; + createdAt: number; + updatedAt: number; +} + +export interface ProposalMeta { + id: string; + spaceSlug: string; + authorId: string; + title: string; + description: string; + status: string; + score: number; + votingEndsAt: number; + finalYes: number; + finalNo: number; + finalAbstain: number; + createdAt: number; + updatedAt: number; +} + +export interface ProposalDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + spaceConfig: SpaceConfig | null; + proposal: ProposalMeta; + votes: Record; + finalVotes: Record; +} + +// ── Schema registration ── + +export const proposalSchema: DocSchema = { + module: 'vote', + collection: 'proposals', + version: 1, + init: (): ProposalDoc => ({ + meta: { + module: 'vote', + collection: 'proposals', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + spaceConfig: null, + proposal: { + id: '', + spaceSlug: '', + authorId: '', + title: '', + description: '', + status: 'RANKING', + score: 0, + votingEndsAt: 0, + finalYes: 0, + finalNo: 0, + finalAbstain: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + votes: {}, + finalVotes: {}, + }), +}; + +// ── Helpers ── + +export function proposalDocId(space: string, proposalId: string) { + return `${space}:vote:proposals:${proposalId}` as const; +} diff --git a/modules/rwork/local-first-client.ts b/modules/rwork/local-first-client.ts new file mode 100644 index 0000000..a7eefa8 --- /dev/null +++ b/modules/rwork/local-first-client.ts @@ -0,0 +1,104 @@ +/** + * rWork Local-First Client + * + * Wraps the shared local-first stack into a work/kanban-specific API. + */ + +import * as Automerge from '@automerge/automerge'; +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 { boardSchema, boardDocId } from './schemas'; +import type { BoardDoc, TaskItem, BoardMeta } from './schemas'; + +export class WorkLocalFirstClient { + #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(boardSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + get isInitialized(): boolean { return this.#initialized; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('work', 'boards'); + for (const docId of cachedIds) { + const binary = await this.#store.load(docId); + if (binary) this.#documents.open(docId, boardSchema, binary); + } + 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('[WorkClient] Working offline'); } + this.#initialized = true; + } + + async subscribeBoard(boardId: string): Promise { + const docId = boardDocId(this.#space, boardId) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, boardSchema, binary) + : this.#documents.open(docId, boardSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getBoard(boardId: string): BoardDoc | undefined { + return this.#documents.get(boardDocId(this.#space, boardId) as DocumentId); + } + + updateTask(boardId: string, taskId: string, changes: Partial): void { + const docId = boardDocId(this.#space, boardId) as DocumentId; + this.#sync.change(docId, `Update task ${taskId}`, (d) => { + if (!d.tasks[taskId]) { + d.tasks[taskId] = { id: taskId, spaceId: boardId, title: '', description: '', status: 'TODO', priority: null, labels: [], assigneeId: null, createdBy: null, sortOrder: 0, createdAt: Date.now(), updatedAt: Date.now(), ...changes }; + } else { + Object.assign(d.tasks[taskId], changes); + d.tasks[taskId].updatedAt = Date.now(); + } + }); + } + + deleteTask(boardId: string, taskId: string): void { + const docId = boardDocId(this.#space, boardId) as DocumentId; + this.#sync.change(docId, `Delete task ${taskId}`, (d) => { delete d.tasks[taskId]; }); + } + + updateBoard(boardId: string, changes: Partial): void { + const docId = boardDocId(this.#space, boardId) as DocumentId; + this.#sync.change(docId, 'Update board', (d) => { + Object.assign(d.board, changes); + d.board.updatedAt = Date.now(); + }); + } + + onChange(boardId: string, cb: (doc: BoardDoc) => void): () => void { + return this.#sync.onChange(boardDocId(this.#space, boardId) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rwork/mod.ts b/modules/rwork/mod.ts index 441fd33..0dd7f2a 100644 --- a/modules/rwork/mod.ts +++ b/modules/rwork/mod.ts @@ -8,12 +8,16 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import * as Automerge from '@automerge/automerge'; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; -import type { RSpaceModule } from "../../shared/module"; +import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { boardSchema, boardDocId } from './schemas'; +import type { BoardDoc, TaskItem } from './schemas'; const routes = new Hono(); @@ -71,7 +75,50 @@ async function seedDemoIfEmpty() { } } -initDB().then(seedDemoIfEmpty); +// ── Local-first helpers ── +let _syncServer: SyncServer | null = null; + +function isLocalFirst(space: string): boolean { + if (!_syncServer) return false; + return _syncServer.getDocIds().some((id) => id.startsWith(`${space}:work:`)); +} + +function writeTaskToAutomerge(space: string, boardId: string, taskId: string, data: Partial) { + if (!_syncServer) return; + const docId = boardDocId(space, boardId); + const existing = _syncServer.getDoc(docId); + if (!existing) return; + _syncServer.changeDoc(docId, `Update task ${taskId}`, (d) => { + if (!d.tasks[taskId]) { + d.tasks[taskId] = { + id: taskId, + spaceId: boardId, + title: '', + description: '', + status: 'TODO', + priority: null, + labels: [], + assigneeId: null, + createdBy: null, + sortOrder: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + ...data, + } as TaskItem; + } else { + Object.assign(d.tasks[taskId], data); + d.tasks[taskId].updatedAt = Date.now(); + } + }); +} + +function deleteTaskFromAutomerge(space: string, boardId: string, taskId: string) { + if (!_syncServer) return; + const docId = boardDocId(space, boardId); + _syncServer.changeDoc(docId, `Delete task ${taskId}`, (d) => { + delete d.tasks[taskId]; + }); +} // ── API: Spaces ── @@ -236,9 +283,26 @@ export const workModule: RSpaceModule = { icon: "📋", description: "Kanban workspace boards for collaborative task management", scoping: { defaultScope: 'space', userConfigurable: false }, + docSchemas: [{ pattern: '{space}:work:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init }], routes, standaloneDomain: "rwork.online", landingPage: renderLanding, + async onInit(ctx) { + _syncServer = ctx.syncServer; + await initDB(); + await seedDemoIfEmpty(); + }, + async onSpaceCreate(ctx: SpaceLifecycleContext) { + if (!_syncServer) return; + const docId = boardDocId(ctx.spaceSlug, ctx.spaceSlug); + const doc = Automerge.init(); + const initialized = Automerge.change(doc, 'Init board', (d) => { + d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: ctx.spaceSlug, createdAt: Date.now() }; + d.board = { id: ctx.spaceSlug, name: ctx.spaceSlug, slug: ctx.spaceSlug, description: '', icon: null, ownerDid: ctx.ownerDID, statuses: ['TODO', 'IN_PROGRESS', 'DONE'], labels: [], createdAt: Date.now(), updatedAt: Date.now() }; + d.tasks = {}; + }); + _syncServer.setDoc(docId, initialized); + }, feeds: [ { id: "task-activity", diff --git a/modules/rwork/schemas.ts b/modules/rwork/schemas.ts new file mode 100644 index 0000000..33dc355 --- /dev/null +++ b/modules/rwork/schemas.ts @@ -0,0 +1,110 @@ +/** + * rWork Automerge document schemas. + * + * Granularity: one Automerge document per board/workspace. + * DocId format: {space}:work:boards:{boardId} + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface TaskItem { + id: string; + spaceId: string; + title: string; + description: string; + status: string; + priority: string | null; + labels: string[]; + assigneeId: string | null; + createdBy: string | null; + sortOrder: number; + createdAt: number; + updatedAt: number; +} + +export interface BoardMeta { + id: string; + name: string; + slug: string; + description: string; + icon: string | null; + ownerDid: string | null; + statuses: string[]; + labels: string[]; + createdAt: number; + updatedAt: number; +} + +export interface BoardDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + board: BoardMeta; + tasks: Record; +} + +// ── Schema registration ── + +export const boardSchema: DocSchema = { + module: 'work', + collection: 'boards', + version: 1, + init: (): BoardDoc => ({ + meta: { + module: 'work', + collection: 'boards', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + board: { + id: '', + name: 'Untitled Board', + slug: '', + description: '', + icon: null, + ownerDid: null, + statuses: ['TODO', 'IN_PROGRESS', 'DONE'], + labels: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }, + tasks: {}, + }), +}; + +// ── Helpers ── + +export function boardDocId(space: string, boardId: string) { + return `${space}:work:boards:${boardId}` as const; +} + +export function createTaskItem( + id: string, + spaceId: string, + title: string, + opts: Partial = {}, +): TaskItem { + const now = Date.now(); + return { + id, + spaceId, + title, + description: '', + status: 'TODO', + priority: null, + labels: [], + assigneeId: null, + createdBy: null, + sortOrder: 0, + createdAt: now, + updatedAt: now, + ...opts, + }; +}