feat: add Automerge schemas + local-first-clients for tier-3 modules

- rdata: shared analytics dashboard config
- rphotos: shared album curation and photo annotations
- rtube: shared playlists and watch party sync
- rpubs: local-first-client wrapping existing draft schemas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 17:47:37 -07:00
parent d4bb1daa7b
commit a504a24a55
7 changed files with 565 additions and 0 deletions

View File

@ -0,0 +1,72 @@
/**
* rData Local-First Client
*
* Syncs shared analytics dashboard config across space members.
*/
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 { dataSchema, dataDocId } from './schemas';
import type { DataDoc, TrackedApp } from './schemas';
export class DataLocalFirstClient {
#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(dataSchema);
}
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('data', 'config');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) this.#documents.open<DataDoc>(docId, dataSchema, binary);
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[DataClient] Working offline'); }
this.#initialized = true;
}
async subscribe(): Promise<DataDoc | null> {
const docId = dataDocId(this.#space) as DocumentId;
let doc = this.#documents.get<DataDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<DataDoc>(docId, dataSchema, binary)
: this.#documents.open<DataDoc>(docId, dataSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getDoc(): DataDoc | undefined { return this.#documents.get<DataDoc>(dataDocId(this.#space) as DocumentId); }
onChange(cb: (doc: DataDoc) => void): () => void { return this.#sync.onChange(dataDocId(this.#space) as DocumentId, cb as (doc: any) => void); }
addTrackedApp(app: TrackedApp): void {
const docId = dataDocId(this.#space) as DocumentId;
this.#sync.change<DataDoc>(docId, `Track ${app.name}`, (d) => { d.trackedApps[app.id] = app; });
}
removeTrackedApp(appId: string): void {
const docId = dataDocId(this.#space) as DocumentId;
this.#sync.change<DataDoc>(docId, `Untrack app`, (d) => { delete d.trackedApps[appId]; });
}
async disconnect(): Promise<void> { await this.#sync.flush(); this.#sync.disconnect(); }
}

70
modules/rdata/schemas.ts Normal file
View File

@ -0,0 +1,70 @@
/**
* rData Automerge document schemas.
*
* Lightweight sync for shared analytics dashboard configuration.
* The actual analytics data comes from the Umami API; this doc
* syncs which apps/metrics space members want to track together.
*
* DocId format: {space}:data:config
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Document types ──
export interface TrackedApp {
id: string;
name: string;
url: string;
addedBy: string | null;
addedAt: number;
}
export interface DataDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
trackedApps: Record<string, TrackedApp>;
dashboardConfig: {
refreshInterval: number;
defaultDateRange: string;
};
}
// ── Schema registration ──
export const dataSchema: DocSchema<DataDoc> = {
module: 'data',
collection: 'config',
version: 1,
init: (): DataDoc => ({
meta: {
module: 'data',
collection: 'config',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
trackedApps: {},
dashboardConfig: {
refreshInterval: 30000,
defaultDateRange: '7d',
},
}),
migrate: (doc: any, _fromVersion: number) => {
if (!doc.trackedApps) doc.trackedApps = {};
if (!doc.dashboardConfig) doc.dashboardConfig = { refreshInterval: 30000, defaultDateRange: '7d' };
doc.meta.version = 1;
return doc;
},
};
// ── Helpers ──
export function dataDocId(space: string) {
return `${space}:data:config` as const;
}

View File

@ -0,0 +1,82 @@
/**
* rPhotos Local-First Client
*
* Syncs shared album curation and photo annotations.
*/
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 { photosSchema, photosDocId } from './schemas';
import type { PhotosDoc, SharedAlbum, PhotoAnnotation } from './schemas';
export class PhotosLocalFirstClient {
#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(photosSchema);
}
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('photos', 'albums');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) this.#documents.open<PhotosDoc>(docId, photosSchema, binary);
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[PhotosClient] Working offline'); }
this.#initialized = true;
}
async subscribe(): Promise<PhotosDoc | null> {
const docId = photosDocId(this.#space) as DocumentId;
let doc = this.#documents.get<PhotosDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<PhotosDoc>(docId, photosSchema, binary)
: this.#documents.open<PhotosDoc>(docId, photosSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getDoc(): PhotosDoc | undefined { return this.#documents.get<PhotosDoc>(photosDocId(this.#space) as DocumentId); }
onChange(cb: (doc: PhotosDoc) => void): () => void { return this.#sync.onChange(photosDocId(this.#space) as DocumentId, cb as (doc: any) => void); }
shareAlbum(album: SharedAlbum): void {
const docId = photosDocId(this.#space) as DocumentId;
this.#sync.change<PhotosDoc>(docId, `Share album ${album.name}`, (d) => { d.sharedAlbums[album.id] = album; });
}
unshareAlbum(albumId: string): void {
const docId = photosDocId(this.#space) as DocumentId;
this.#sync.change<PhotosDoc>(docId, `Unshare album`, (d) => { delete d.sharedAlbums[albumId]; });
}
annotatePhoto(annotation: PhotoAnnotation): void {
const docId = photosDocId(this.#space) as DocumentId;
this.#sync.change<PhotosDoc>(docId, `Annotate photo`, (d) => { d.annotations[annotation.assetId] = annotation; });
}
removeAnnotation(assetId: string): void {
const docId = photosDocId(this.#space) as DocumentId;
this.#sync.change<PhotosDoc>(docId, `Remove annotation`, (d) => { delete d.annotations[assetId]; });
}
async disconnect(): Promise<void> { await this.#sync.flush(); this.#sync.disconnect(); }
}

View File

@ -0,0 +1,71 @@
/**
* rPhotos Automerge document schemas.
*
* Syncs shared album curation and photo annotations across space members.
* Actual photo files remain on the Immich server; this doc manages
* which albums are shared and any collaborative annotations.
*
* DocId format: {space}:photos:albums
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Document types ──
export interface SharedAlbum {
id: string;
name: string;
description: string;
sharedBy: string | null;
sharedAt: number;
}
export interface PhotoAnnotation {
assetId: string;
note: string;
authorDid: string;
createdAt: number;
}
export interface PhotosDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
sharedAlbums: Record<string, SharedAlbum>;
annotations: Record<string, PhotoAnnotation>;
}
// ── Schema registration ──
export const photosSchema: DocSchema<PhotosDoc> = {
module: 'photos',
collection: 'albums',
version: 1,
init: (): PhotosDoc => ({
meta: {
module: 'photos',
collection: 'albums',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
sharedAlbums: {},
annotations: {},
}),
migrate: (doc: any, _fromVersion: number) => {
if (!doc.sharedAlbums) doc.sharedAlbums = {};
if (!doc.annotations) doc.annotations = {};
doc.meta.version = 1;
return doc;
},
};
// ── Helpers ──
export function photosDocId(space: string) {
return `${space}:photos:albums` as const;
}

View File

@ -0,0 +1,93 @@
/**
* rPubs Local-First Client
*
* Wraps existing Automerge schemas for collaborative document editing.
* Each draft is a separate Automerge doc with shared markdown content.
*/
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 { pubsDraftSchema, pubsDocId } from './schemas';
import type { PubsDoc, PubsDraftMeta } from './schemas';
export class PubsLocalFirstClient {
#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(pubsDraftSchema);
}
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('pubs', 'drafts');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) this.#documents.open<PubsDoc>(docId, pubsDraftSchema, binary);
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[PubsClient] Working offline'); }
this.#initialized = true;
}
async subscribeDraft(draftId: string): Promise<PubsDoc | null> {
const docId = pubsDocId(this.#space, draftId) as DocumentId;
let doc = this.#documents.get<PubsDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<PubsDoc>(docId, pubsDraftSchema, binary)
: this.#documents.open<PubsDoc>(docId, pubsDraftSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getDraft(draftId: string): PubsDoc | undefined {
return this.#documents.get<PubsDoc>(pubsDocId(this.#space, draftId) as DocumentId);
}
onDraftChange(draftId: string, cb: (doc: PubsDoc) => void): () => void {
return this.#sync.onChange(pubsDocId(this.#space, draftId) as DocumentId, cb as (doc: any) => void);
}
/** List all cached draft IDs */
async listDrafts(): Promise<string[]> {
return this.#store.listByModule('pubs', 'drafts');
}
// ── Draft CRUD ──
updateContent(draftId: string, content: string): void {
const docId = pubsDocId(this.#space, draftId) as DocumentId;
this.#sync.change<PubsDoc>(docId, `Update content`, (d) => {
d.content = content;
d.draft.updatedAt = Date.now();
});
}
updateMeta(draftId: string, meta: Partial<PubsDraftMeta>): void {
const docId = pubsDocId(this.#space, draftId) as DocumentId;
this.#sync.change<PubsDoc>(docId, `Update draft meta`, (d) => {
if (meta.title !== undefined) d.draft.title = meta.title;
if (meta.author !== undefined) d.draft.author = meta.author;
if (meta.format !== undefined) d.draft.format = meta.format;
d.draft.updatedAt = Date.now();
});
}
async disconnect(): Promise<void> { await this.#sync.flush(); this.#sync.disconnect(); }
}

View File

@ -0,0 +1,98 @@
/**
* rTube Local-First Client
*
* Syncs shared playlists and watch party state.
*/
import { DocumentManager } from '../../shared/local-first/document';
import type { DocumentId } from '../../shared/local-first/document';
import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { tubeSchema, tubeDocId } from './schemas';
import type { TubeDoc, Playlist, PlaylistEntry } from './schemas';
export class TubeLocalFirstClient {
#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(tubeSchema);
}
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('tube', 'playlists');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) this.#documents.open<TubeDoc>(docId, tubeSchema, binary);
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[TubeClient] Working offline'); }
this.#initialized = true;
}
async subscribe(): Promise<TubeDoc | null> {
const docId = tubeDocId(this.#space) as DocumentId;
let doc = this.#documents.get<TubeDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<TubeDoc>(docId, tubeSchema, binary)
: this.#documents.open<TubeDoc>(docId, tubeSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getDoc(): TubeDoc | undefined { return this.#documents.get<TubeDoc>(tubeDocId(this.#space) as DocumentId); }
onChange(cb: (doc: TubeDoc) => void): () => void { return this.#sync.onChange(tubeDocId(this.#space) as DocumentId, cb as (doc: any) => void); }
savePlaylist(playlist: Playlist): void {
const docId = tubeDocId(this.#space) as DocumentId;
this.#sync.change<TubeDoc>(docId, `Save playlist ${playlist.name}`, (d) => { d.playlists[playlist.id] = playlist; });
}
deletePlaylist(playlistId: string): void {
const docId = tubeDocId(this.#space) as DocumentId;
this.#sync.change<TubeDoc>(docId, `Delete playlist`, (d) => { delete d.playlists[playlistId]; });
}
addToPlaylist(playlistId: string, entry: PlaylistEntry): void {
const docId = tubeDocId(this.#space) as DocumentId;
this.#sync.change<TubeDoc>(docId, `Add to playlist`, (d) => {
if (d.playlists[playlistId]) d.playlists[playlistId].entries.push(entry);
});
}
startWatchParty(videoName: string, startedBy: string | null): void {
const docId = tubeDocId(this.#space) as DocumentId;
this.#sync.change<TubeDoc>(docId, `Start watch party`, (d) => {
d.watchParty = { videoName, startedBy, startedAt: Date.now(), position: 0, playing: true };
});
}
updateWatchPartyPosition(position: number, playing: boolean): void {
const docId = tubeDocId(this.#space) as DocumentId;
this.#sync.change<TubeDoc>(docId, `Sync playback`, (d) => {
if (d.watchParty) { d.watchParty.position = position; d.watchParty.playing = playing; }
});
}
endWatchParty(): void {
const docId = tubeDocId(this.#space) as DocumentId;
this.#sync.change<TubeDoc>(docId, `End watch party`, (d) => { d.watchParty = null; });
}
async disconnect(): Promise<void> { await this.#sync.flush(); this.#sync.disconnect(); }
}

79
modules/rtube/schemas.ts Normal file
View File

@ -0,0 +1,79 @@
/**
* rTube Automerge document schemas.
*
* Syncs shared playlists and watch party state across space members.
* Actual video files remain on R2/S3; this doc manages collaborative
* curation and viewing sessions.
*
* DocId format: {space}:tube:playlists
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Document types ──
export interface PlaylistEntry {
videoName: string;
addedBy: string | null;
addedAt: number;
}
export interface Playlist {
id: string;
name: string;
entries: PlaylistEntry[];
createdBy: string | null;
createdAt: number;
}
export interface WatchParty {
videoName: string;
startedBy: string | null;
startedAt: number;
/** Playback position in seconds */
position: number;
playing: boolean;
}
export interface TubeDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
playlists: Record<string, Playlist>;
watchParty: WatchParty | null;
}
// ── Schema registration ──
export const tubeSchema: DocSchema<TubeDoc> = {
module: 'tube',
collection: 'playlists',
version: 1,
init: (): TubeDoc => ({
meta: {
module: 'tube',
collection: 'playlists',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
playlists: {},
watchParty: null,
}),
migrate: (doc: any, _fromVersion: number) => {
if (!doc.playlists) doc.playlists = {};
if (!('watchParty' in doc)) doc.watchParty = null;
doc.meta.version = 1;
return doc;
},
};
// ── Helpers ──
export function tubeDocId(space: string) {
return `${space}:tube:playlists` as const;
}