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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 14:01:58 -08:00
parent 6a7f21dc19
commit ef3d0ce447
30 changed files with 2144 additions and 22 deletions

View File

@ -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<void> {
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<BooksCatalogDoc>(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<BooksCatalogDoc | null> {
const docId = booksCatalogDocId(this.#space) as DocumentId;
let doc = this.#documents.get<BooksCatalogDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<BooksCatalogDoc>(docId, booksCatalogSchema, binary)
: this.#documents.open<BooksCatalogDoc>(docId, booksCatalogSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getCatalog(): BooksCatalogDoc | undefined {
return this.#documents.get<BooksCatalogDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -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();

69
modules/rbooks/schemas.ts Normal file
View File

@ -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<string, BookItem>;
}
// ── Schema registration ──
export const booksCatalogSchema: DocSchema<BooksCatalogDoc> = {
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;
}

View File

@ -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<void> {
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<CalendarDoc>(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<CalendarDoc | null> {
const docId = calendarDocId(this.#space) as DocumentId;
let doc = this.#documents.get<CalendarDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<CalendarDoc>(docId, calendarSchema, binary)
: this.#documents.open<CalendarDoc>(docId, calendarSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getCalendar(): CalendarDoc | undefined {
return this.#documents.get<CalendarDoc>(calendarDocId(this.#space) as DocumentId);
}
updateEvent(eventId: string, changes: Partial<CalendarEvent>): void {
const docId = calendarDocId(this.#space) as DocumentId;
this.#sync.change<CalendarDoc>(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<CalendarDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -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",

94
modules/rcal/schemas.ts Normal file
View File

@ -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<string, CalendarSource>;
events: Record<string, CalendarEvent>;
}
// ── Schema registration ──
export const calendarSchema: DocSchema<CalendarDoc> = {
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;
}

View File

@ -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<void> {
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<CatalogDoc>(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<OrderDoc>(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<CatalogDoc | null> {
const docId = catalogDocId(this.#space) as DocumentId;
let doc = this.#documents.get<CatalogDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<CatalogDoc>(docId, catalogSchema, binary)
: this.#documents.open<CatalogDoc>(docId, catalogSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
async subscribeOrder(orderId: string): Promise<OrderDoc | null> {
const docId = orderDocId(this.#space, orderId) as DocumentId;
let doc = this.#documents.get<OrderDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<OrderDoc>(docId, orderSchema, binary)
: this.#documents.open<OrderDoc>(docId, orderSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getCatalog(): CatalogDoc | undefined {
return this.#documents.get<CatalogDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -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",

147
modules/rcart/schemas.ts Normal file
View File

@ -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<string, CatalogEntry>;
}
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<CatalogDoc> = {
module: 'cart',
collection: 'catalog',
version: 1,
init: (): CatalogDoc => ({
meta: {
module: 'cart',
collection: 'catalog',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
items: {},
}),
};
export const orderSchema: DocSchema<OrderDoc> = {
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;
}

View File

@ -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<void> {
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<FilesDoc>(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<FilesDoc | null> {
const docId = filesDocId(this.#space, sharedSpace) as DocumentId;
let doc = this.#documents.get<FilesDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<FilesDoc>(docId, filesSchema, binary)
: this.#documents.open<FilesDoc>(docId, filesSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
updateFile(sharedSpace: string, fileId: string, changes: Partial<MediaFile>): void {
const docId = filesDocId(this.#space, sharedSpace) as DocumentId;
this.#sync.change<FilesDoc>(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<MemoryCard>): void {
const docId = filesDocId(this.#space, sharedSpace) as DocumentId;
this.#sync.change<FilesDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -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: [

82
modules/rfiles/schemas.ts Normal file
View File

@ -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<string, MediaFile>;
memoryCards: Record<string, MemoryCard>;
}
// ── Schema registration ──
export const filesSchema: DocSchema<FilesDoc> = {
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;
}

View File

@ -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<void> {
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<FundsDoc>(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<FundsDoc | null> {
const docId = fundsDocId(this.#space) as DocumentId;
let doc = this.#documents.get<FundsDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<FundsDoc>(docId, fundsSchema, binary)
: this.#documents.open<FundsDoc>(docId, fundsSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getFlows(): FundsDoc | undefined {
return this.#documents.get<FundsDoc>(fundsDocId(this.#space) as DocumentId);
}
addSpaceFlow(flow: SpaceFlow): void {
const docId = fundsDocId(this.#space) as DocumentId;
this.#sync.change<FundsDoc>(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<FundsDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -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: [
{

56
modules/rfunds/schemas.ts Normal file
View File

@ -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<string, SpaceFlow>;
}
// ── Schema registration ──
export const fundsSchema: DocSchema<FundsDoc> = {
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;
}

View File

@ -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<void> {
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<MailboxDoc>(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<MailboxDoc | null> {
const docId = mailboxDocId(this.#space, mailboxId) as DocumentId;
let doc = this.#documents.get<MailboxDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<MailboxDoc>(docId, mailboxSchema, binary)
: this.#documents.open<MailboxDoc>(docId, mailboxSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getMailbox(mailboxId: string): MailboxDoc | undefined {
return this.#documents.get<MailboxDoc>(mailboxDocId(this.#space, mailboxId) as DocumentId);
}
updateThread(mailboxId: string, threadId: string, changes: Partial<ThreadItem>): void {
const docId = mailboxDocId(this.#space, mailboxId) as DocumentId;
this.#sync.change<MailboxDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -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: [
{

146
modules/rinbox/schemas.ts Normal file
View File

@ -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<string, ThreadItem>;
approvals: Record<string, ApprovalItem>;
}
// ── Schema registration ──
export const mailboxSchema: DocSchema<MailboxDoc> = {
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;
}

View File

@ -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<void> {
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<SplatScenesDoc>(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<SplatScenesDoc | null> {
const docId = splatScenesDocId(this.#space) as DocumentId;
let doc = this.#documents.get<SplatScenesDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<SplatScenesDoc>(docId, splatScenesSchema, binary)
: this.#documents.open<SplatScenesDoc>(docId, splatScenesSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getScenes(): SplatScenesDoc | undefined {
return this.#documents.get<SplatScenesDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -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();

81
modules/rsplat/schemas.ts Normal file
View File

@ -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<string, SplatItem>;
}
// ── Schema registration ──
export const splatScenesSchema: DocSchema<SplatScenesDoc> = {
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;
}

View File

@ -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<void> {
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<TripDoc>(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<TripDoc | null> {
const docId = tripDocId(this.#space, tripId) as DocumentId;
let doc = this.#documents.get<TripDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<TripDoc>(docId, tripSchema, binary)
: this.#documents.open<TripDoc>(docId, tripSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getTrip(tripId: string): TripDoc | undefined {
return this.#documents.get<TripDoc>(tripDocId(this.#space, tripId) as DocumentId);
}
updateTrip(tripId: string, changes: Partial<TripMeta>): void {
const docId = tripDocId(this.#space, tripId) as DocumentId;
this.#sync.change<TripDoc>(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<TripDoc>(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<TripDoc>(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<TripDoc>(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<TripDoc>(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<TripDoc>(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<TripDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -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: [
{

151
modules/rtrips/schemas.ts Normal file
View File

@ -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<string, Destination>;
itinerary: Record<string, ItineraryItem>;
bookings: Record<string, Booking>;
expenses: Record<string, Expense>;
packingItems: Record<string, PackingItem>;
}
// ── Schema registration ──
export const tripSchema: DocSchema<TripDoc> = {
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;
}

View File

@ -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<void> {
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<ProposalDoc>(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<ProposalDoc | null> {
const docId = proposalDocId(this.#space, proposalId) as DocumentId;
let doc = this.#documents.get<ProposalDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<ProposalDoc>(docId, proposalSchema, binary)
: this.#documents.open<ProposalDoc>(docId, proposalSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getProposal(proposalId: string): ProposalDoc | undefined {
return this.#documents.get<ProposalDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -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",

117
modules/rvote/schemas.ts Normal file
View File

@ -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<string, VoteItem>;
finalVotes: Record<string, FinalVoteItem>;
}
// ── Schema registration ──
export const proposalSchema: DocSchema<ProposalDoc> = {
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;
}

View File

@ -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<void> {
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<BoardDoc>(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<BoardDoc | null> {
const docId = boardDocId(this.#space, boardId) as DocumentId;
let doc = this.#documents.get<BoardDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<BoardDoc>(docId, boardSchema, binary)
: this.#documents.open<BoardDoc>(docId, boardSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getBoard(boardId: string): BoardDoc | undefined {
return this.#documents.get<BoardDoc>(boardDocId(this.#space, boardId) as DocumentId);
}
updateTask(boardId: string, taskId: string, changes: Partial<TaskItem>): void {
const docId = boardDocId(this.#space, boardId) as DocumentId;
this.#sync.change<BoardDoc>(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<BoardDoc>(docId, `Delete task ${taskId}`, (d) => { delete d.tasks[taskId]; });
}
updateBoard(boardId: string, changes: Partial<BoardMeta>): void {
const docId = boardDocId(this.#space, boardId) as DocumentId;
this.#sync.change<BoardDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -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<TaskItem>) {
if (!_syncServer) return;
const docId = boardDocId(space, boardId);
const existing = _syncServer.getDoc<BoardDoc>(docId);
if (!existing) return;
_syncServer.changeDoc<BoardDoc>(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<BoardDoc>(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<BoardDoc>();
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",

110
modules/rwork/schemas.ts Normal file
View File

@ -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<string, TaskItem>;
}
// ── Schema registration ──
export const boardSchema: DocSchema<BoardDoc> = {
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> = {},
): 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,
};
}