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:
parent
6a7f21dc19
commit
ef3d0ce447
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue