Add 7-layer local-first data infrastructure
Crypto (PRF/HKDF/AES-256-GCM per-doc keys), Document (schema + manager), Storage (encrypted IndexedDB), Sync (multi-doc WebSocket client + server), Compute (local/server-delegated transforms), Query (views + search), and Memory Card interchange format. 2919 lines across 10 files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b412063684
commit
3a0d376f23
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Layer 5: Compute (Server) — Server-side transform runner.
|
||||
*
|
||||
* Handles POST /:space/api/compute/:transformId requests from clients
|
||||
* that can't run a transform locally (e.g. PDF generation, image thumbnailing).
|
||||
*
|
||||
* Usage in server/index.ts:
|
||||
* import { createComputeRouter, registerServerTransform } from './local-first/compute-server';
|
||||
* const computeRouter = createComputeRouter();
|
||||
* app.route('/:space/api/compute', computeRouter);
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface ServerTransform<In = unknown, Out = unknown> {
|
||||
id: string;
|
||||
/** Execute the transform on the server */
|
||||
execute(input: In, context: TransformContext): Promise<Out>;
|
||||
}
|
||||
|
||||
export interface TransformContext {
|
||||
space: string;
|
||||
/** Auth claims from EncryptID JWT (null if unauthenticated) */
|
||||
claims: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REGISTRY
|
||||
// ============================================================================
|
||||
|
||||
const transforms = new Map<string, ServerTransform<any, any>>();
|
||||
|
||||
export function registerServerTransform<In, Out>(transform: ServerTransform<In, Out>): void {
|
||||
transforms.set(transform.id, transform);
|
||||
}
|
||||
|
||||
export function getServerTransform(id: string): ServerTransform | undefined {
|
||||
return transforms.get(id);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HONO ROUTER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a Hono router that handles compute requests.
|
||||
* Mount at /:space/api/compute
|
||||
*/
|
||||
export function createComputeRouter(): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// List available transforms
|
||||
router.get('/', (c) => {
|
||||
const list = Array.from(transforms.keys());
|
||||
return c.json({ transforms: list });
|
||||
});
|
||||
|
||||
// Execute a transform
|
||||
router.post('/:transformId', async (c) => {
|
||||
const transformId = c.req.param('transformId');
|
||||
const transform = transforms.get(transformId);
|
||||
|
||||
if (!transform) {
|
||||
return c.json({ error: `Transform "${transformId}" not found` }, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const input = body.input;
|
||||
|
||||
const context: TransformContext = {
|
||||
space: c.req.param('space') || 'demo',
|
||||
claims: (c as any).get?.('claims') ?? null,
|
||||
};
|
||||
|
||||
const output = await transform.execute(input, context);
|
||||
return c.json({ output });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Transform execution failed';
|
||||
console.error(`[ComputeServer] Transform "${transformId}" failed:`, e);
|
||||
return c.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
/**
|
||||
* Layer 4: Sync (Server) — Multi-document WebSocket sync relay.
|
||||
*
|
||||
* Acts as a sync peer for multiple Automerge documents. Can operate in two modes:
|
||||
*
|
||||
* 1. **Relay mode** (encrypted docs): Forwards encrypted sync messages between peers.
|
||||
* Server can't read document content — just routes by docId.
|
||||
*
|
||||
* 2. **Participant mode** (shared/unencrypted docs): Server maintains its own copy
|
||||
* of each document, can read/index/validate. Used for Intent/Claim pattern.
|
||||
*
|
||||
* Wire protocol matches the client (shared/local-first/sync.ts).
|
||||
*/
|
||||
|
||||
import * as Automerge from '@automerge/automerge';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface SyncMessage {
|
||||
type: 'sync';
|
||||
docId: string;
|
||||
data: number[];
|
||||
}
|
||||
|
||||
interface SubscribeMessage {
|
||||
type: 'subscribe';
|
||||
docIds: string[];
|
||||
}
|
||||
|
||||
interface UnsubscribeMessage {
|
||||
type: 'unsubscribe';
|
||||
docIds: string[];
|
||||
}
|
||||
|
||||
interface AwarenessMessage {
|
||||
type: 'awareness';
|
||||
docId: string;
|
||||
peer: string;
|
||||
cursor?: { x: number; y: number };
|
||||
selection?: string;
|
||||
username?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
type WireMessage =
|
||||
| SyncMessage
|
||||
| SubscribeMessage
|
||||
| UnsubscribeMessage
|
||||
| AwarenessMessage
|
||||
| { type: 'ping' }
|
||||
| { type: 'pong' };
|
||||
|
||||
export interface Peer {
|
||||
id: string;
|
||||
ws: WebSocket;
|
||||
subscribedDocs: Set<string>;
|
||||
syncStates: Map<string, Automerge.SyncState>;
|
||||
claims?: Record<string, unknown>; // Auth claims
|
||||
}
|
||||
|
||||
export interface SyncServerOptions {
|
||||
/** If true, server maintains Automerge docs and participates in sync (default: true) */
|
||||
participantMode?: boolean;
|
||||
/** Called when a document changes (participant mode only) */
|
||||
onDocChange?: (docId: string, doc: Automerge.Doc<any>) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SyncServer
|
||||
// ============================================================================
|
||||
|
||||
export class SyncServer {
|
||||
#peers = new Map<string, Peer>();
|
||||
#docs = new Map<string, Automerge.Doc<any>>();
|
||||
#docSubscribers = new Map<string, Set<string>>(); // docId → Set<peerId>
|
||||
#participantMode: boolean;
|
||||
#onDocChange?: (docId: string, doc: Automerge.Doc<any>) => void;
|
||||
|
||||
constructor(opts: SyncServerOptions = {}) {
|
||||
this.#participantMode = opts.participantMode ?? true;
|
||||
this.#onDocChange = opts.onDocChange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new WebSocket peer.
|
||||
*/
|
||||
addPeer(peerId: string, ws: WebSocket, claims?: Record<string, unknown>): Peer {
|
||||
const peer: Peer = {
|
||||
id: peerId,
|
||||
ws,
|
||||
subscribedDocs: new Set(),
|
||||
syncStates: new Map(),
|
||||
claims,
|
||||
};
|
||||
this.#peers.set(peerId, peer);
|
||||
return peer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a peer and clean up subscriptions.
|
||||
*/
|
||||
removePeer(peerId: string): void {
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (!peer) return;
|
||||
|
||||
// Remove from all document subscriber sets
|
||||
for (const docId of peer.subscribedDocs) {
|
||||
const subs = this.#docSubscribers.get(docId);
|
||||
if (subs) {
|
||||
subs.delete(peerId);
|
||||
if (subs.size === 0) {
|
||||
this.#docSubscribers.delete(docId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.#peers.delete(peerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming message from a peer.
|
||||
*/
|
||||
handleMessage(peerId: string, raw: string | ArrayBuffer): void {
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (!peer) return;
|
||||
|
||||
try {
|
||||
const data = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
|
||||
const msg: WireMessage = JSON.parse(data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'subscribe':
|
||||
this.#handleSubscribe(peer, msg as SubscribeMessage);
|
||||
break;
|
||||
case 'unsubscribe':
|
||||
this.#handleUnsubscribe(peer, msg as UnsubscribeMessage);
|
||||
break;
|
||||
case 'sync':
|
||||
this.#handleSync(peer, msg as SyncMessage);
|
||||
break;
|
||||
case 'awareness':
|
||||
this.#handleAwareness(peer, msg as AwarenessMessage);
|
||||
break;
|
||||
case 'ping':
|
||||
this.#sendToPeer(peer, { type: 'pong' });
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[SyncServer] Error handling message from ${peerId}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a server-side document (participant mode).
|
||||
*/
|
||||
getDoc<T>(docId: string): Automerge.Doc<T> | undefined {
|
||||
return this.#docs.get(docId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set/replace a server-side document and sync to all subscribed peers.
|
||||
*/
|
||||
setDoc(docId: string, doc: Automerge.Doc<any>): void {
|
||||
this.#docs.set(docId, doc);
|
||||
this.#syncDocToAllPeers(docId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a change to a server-side document (e.g. for Intent/Claim validation).
|
||||
*/
|
||||
changeDoc<T>(docId: string, message: string, fn: (doc: T) => void): Automerge.Doc<T> | null {
|
||||
let doc = this.#docs.get(docId);
|
||||
if (!doc) return null;
|
||||
|
||||
doc = Automerge.change(doc, message, fn as any);
|
||||
this.#docs.set(docId, doc);
|
||||
this.#syncDocToAllPeers(docId);
|
||||
|
||||
if (this.#onDocChange) {
|
||||
this.#onDocChange(docId, doc);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of connected peer IDs.
|
||||
*/
|
||||
getPeerIds(): string[] {
|
||||
return Array.from(this.#peers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscribers for a document.
|
||||
*/
|
||||
getDocSubscribers(docId: string): string[] {
|
||||
return Array.from(this.#docSubscribers.get(docId) ?? []);
|
||||
}
|
||||
|
||||
// ---------- Private ----------
|
||||
|
||||
#handleSubscribe(peer: Peer, msg: SubscribeMessage): void {
|
||||
for (const docId of msg.docIds) {
|
||||
peer.subscribedDocs.add(docId);
|
||||
|
||||
let subs = this.#docSubscribers.get(docId);
|
||||
if (!subs) {
|
||||
subs = new Set();
|
||||
this.#docSubscribers.set(docId, subs);
|
||||
}
|
||||
subs.add(peer.id);
|
||||
|
||||
// Initialize sync state for this peer-doc pair
|
||||
if (!peer.syncStates.has(docId)) {
|
||||
peer.syncStates.set(docId, Automerge.initSyncState());
|
||||
}
|
||||
|
||||
// If participant mode and we have a doc, send initial sync
|
||||
if (this.#participantMode && this.#docs.has(docId)) {
|
||||
this.#sendSyncToPeer(peer, docId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#handleUnsubscribe(peer: Peer, msg: UnsubscribeMessage): void {
|
||||
for (const docId of msg.docIds) {
|
||||
peer.subscribedDocs.delete(docId);
|
||||
peer.syncStates.delete(docId);
|
||||
|
||||
const subs = this.#docSubscribers.get(docId);
|
||||
if (subs) {
|
||||
subs.delete(peer.id);
|
||||
if (subs.size === 0) {
|
||||
this.#docSubscribers.delete(docId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#handleSync(peer: Peer, msg: SyncMessage): void {
|
||||
const { docId, data } = msg;
|
||||
const syncMsg = new Uint8Array(data);
|
||||
|
||||
if (this.#participantMode) {
|
||||
// Server participates: apply sync message to server's doc
|
||||
let doc = this.#docs.get(docId);
|
||||
if (!doc) {
|
||||
// Create an empty doc if this is the first time we see this docId
|
||||
doc = Automerge.init();
|
||||
this.#docs.set(docId, doc);
|
||||
}
|
||||
|
||||
let syncState = peer.syncStates.get(docId) ?? Automerge.initSyncState();
|
||||
const [newDoc, newSyncState] = Automerge.receiveSyncMessage(doc, syncState, syncMsg);
|
||||
|
||||
const changed = newDoc !== doc;
|
||||
this.#docs.set(docId, newDoc);
|
||||
peer.syncStates.set(docId, newSyncState);
|
||||
|
||||
// Send response sync message back to this peer
|
||||
this.#sendSyncToPeer(peer, docId);
|
||||
|
||||
// If doc changed, sync to other subscribers
|
||||
if (changed) {
|
||||
this.#syncDocToOtherPeers(docId, peer.id);
|
||||
if (this.#onDocChange) {
|
||||
this.#onDocChange(docId, newDoc);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Relay mode: forward sync message to all other subscribers
|
||||
const subs = this.#docSubscribers.get(docId);
|
||||
if (!subs) return;
|
||||
|
||||
for (const subPeerId of subs) {
|
||||
if (subPeerId === peer.id) continue;
|
||||
const subPeer = this.#peers.get(subPeerId);
|
||||
if (subPeer) {
|
||||
this.#sendToPeer(subPeer, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#handleAwareness(peer: Peer, msg: AwarenessMessage): void {
|
||||
// Forward awareness to all other subscribers of this document
|
||||
const subs = this.#docSubscribers.get(msg.docId);
|
||||
if (!subs) return;
|
||||
|
||||
for (const subPeerId of subs) {
|
||||
if (subPeerId === peer.id) continue;
|
||||
const subPeer = this.#peers.get(subPeerId);
|
||||
if (subPeer) {
|
||||
this.#sendToPeer(subPeer, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#sendSyncToPeer(peer: Peer, docId: string): void {
|
||||
const doc = this.#docs.get(docId);
|
||||
if (!doc) return;
|
||||
|
||||
let syncState = peer.syncStates.get(docId) ?? Automerge.initSyncState();
|
||||
const [newSyncState, syncMessage] = Automerge.generateSyncMessage(doc, syncState);
|
||||
|
||||
peer.syncStates.set(docId, newSyncState);
|
||||
|
||||
if (syncMessage) {
|
||||
this.#sendToPeer(peer, {
|
||||
type: 'sync',
|
||||
docId,
|
||||
data: Array.from(syncMessage),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#syncDocToAllPeers(docId: string): void {
|
||||
const subs = this.#docSubscribers.get(docId);
|
||||
if (!subs) return;
|
||||
|
||||
for (const peerId of subs) {
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (peer) {
|
||||
this.#sendSyncToPeer(peer, docId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#syncDocToOtherPeers(docId: string, excludePeerId: string): void {
|
||||
const subs = this.#docSubscribers.get(docId);
|
||||
if (!subs) return;
|
||||
|
||||
for (const peerId of subs) {
|
||||
if (peerId === excludePeerId) continue;
|
||||
const peer = this.#peers.get(peerId);
|
||||
if (peer) {
|
||||
this.#sendSyncToPeer(peer, docId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#sendToPeer(peer: Peer, msg: object): void {
|
||||
try {
|
||||
if (peer.ws.readyState === WebSocket.OPEN) {
|
||||
peer.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[SyncServer] Failed to send to peer ${peer.id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Layer 5: Compute — Deterministic transforms that run locally when possible,
|
||||
* delegate to server when not.
|
||||
*
|
||||
* | Transform | Local? | Example |
|
||||
* |---------------------|--------|--------------------------------|
|
||||
* | Markdown → HTML | Yes | Notes rendering |
|
||||
* | Search indexing | Yes | Build index from docs |
|
||||
* | Vote tallies | Yes | Derived from CRDT state |
|
||||
* | PDF generation | No | Server delegate (Typst) |
|
||||
* | Image thumbnailing | No | Server delegate (Sharp) |
|
||||
*
|
||||
* Server fallback: POST /:space/api/compute/:transformId
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A transform: a named, deterministic function that converts input → output.
|
||||
*/
|
||||
export interface Transform<In = unknown, Out = unknown> {
|
||||
/** Unique identifier (e.g. "markdown-to-html", "pdf-generate") */
|
||||
id: string;
|
||||
/** Whether this transform can run in the browser */
|
||||
canRunLocally: boolean;
|
||||
/** Execute the transform */
|
||||
execute(input: In): Promise<Out>;
|
||||
}
|
||||
|
||||
export interface ComputeEngineOptions {
|
||||
/** Base URL for server-side compute endpoint (e.g. "/demo/api/compute") */
|
||||
serverBaseUrl?: string;
|
||||
/** Auth token for server requests */
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ComputeEngine
|
||||
// ============================================================================
|
||||
|
||||
export class ComputeEngine {
|
||||
#transforms = new Map<string, Transform<any, any>>();
|
||||
#serverBaseUrl: string | null;
|
||||
#authToken: string | null;
|
||||
|
||||
constructor(opts: ComputeEngineOptions = {}) {
|
||||
this.#serverBaseUrl = opts.serverBaseUrl ?? null;
|
||||
this.#authToken = opts.authToken ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a transform.
|
||||
*/
|
||||
register<In, Out>(transform: Transform<In, Out>): void {
|
||||
this.#transforms.set(transform.id, transform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a transform. Runs locally if possible, delegates to server otherwise.
|
||||
*/
|
||||
async run<In, Out>(transformId: string, input: In): Promise<Out> {
|
||||
const transform = this.#transforms.get(transformId);
|
||||
|
||||
if (transform?.canRunLocally) {
|
||||
return transform.execute(input) as Promise<Out>;
|
||||
}
|
||||
|
||||
if (transform && !transform.canRunLocally && this.#serverBaseUrl) {
|
||||
return this.#delegateToServer<In, Out>(transformId, input);
|
||||
}
|
||||
|
||||
if (!transform && this.#serverBaseUrl) {
|
||||
// Transform not registered locally — try server
|
||||
return this.#delegateToServer<In, Out>(transformId, input);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Transform "${transformId}" not available: ${
|
||||
transform ? 'requires server but no serverBaseUrl configured' : 'not registered'
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a transform is registered and can run locally.
|
||||
*/
|
||||
canRunLocally(transformId: string): boolean {
|
||||
const t = this.#transforms.get(transformId);
|
||||
return !!t && t.canRunLocally;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a transform is registered.
|
||||
*/
|
||||
has(transformId: string): boolean {
|
||||
return this.#transforms.has(transformId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered transform IDs.
|
||||
*/
|
||||
list(): string[] {
|
||||
return Array.from(this.#transforms.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update auth token (e.g. after login/refresh).
|
||||
*/
|
||||
setAuthToken(token: string): void {
|
||||
this.#authToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update server base URL.
|
||||
*/
|
||||
setServerBaseUrl(url: string): void {
|
||||
this.#serverBaseUrl = url;
|
||||
}
|
||||
|
||||
// ---------- Private ----------
|
||||
|
||||
async #delegateToServer<In, Out>(transformId: string, input: In): Promise<Out> {
|
||||
if (!this.#serverBaseUrl) {
|
||||
throw new Error('No server base URL configured for compute delegation');
|
||||
}
|
||||
|
||||
const url = `${this.#serverBaseUrl}/${transformId}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.#authToken) {
|
||||
headers['Authorization'] = `Bearer ${this.#authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ input }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Compute server error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.output as Out;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BUILT-IN TRANSFORMS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Helper to create a local transform.
|
||||
*/
|
||||
export function localTransform<In, Out>(
|
||||
id: string,
|
||||
fn: (input: In) => Promise<Out> | Out
|
||||
): Transform<In, Out> {
|
||||
return {
|
||||
id,
|
||||
canRunLocally: true,
|
||||
execute: async (input) => fn(input),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to declare a server-only transform (acts as a type contract).
|
||||
*/
|
||||
export function serverTransform<In, Out>(id: string): Transform<In, Out> {
|
||||
return {
|
||||
id,
|
||||
canRunLocally: false,
|
||||
execute: async () => {
|
||||
throw new Error(`Transform "${id}" must run on server`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Layer 1: Crypto — Document-level encryption for local-first data.
|
||||
*
|
||||
* Key hierarchy:
|
||||
* Master Key (PRF output / passphrase) → HKDF
|
||||
* → Space Key (info: "rspace:{spaceId}") → HKDF
|
||||
* → Doc Key (info: "rspace:{spaceId}:{docId}") → AES-256-GCM
|
||||
*
|
||||
* Server never sees plaintext — only encrypted Automerge binary blobs.
|
||||
* Extends the existing EncryptIDKeyManager key derivation pattern.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface EncryptedBlob {
|
||||
/** AES-256-GCM ciphertext (includes auth tag) */
|
||||
ciphertext: Uint8Array;
|
||||
/** 12-byte random nonce */
|
||||
nonce: Uint8Array;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DocCrypto
|
||||
// ============================================================================
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* DocCrypto — derives per-space and per-document encryption keys from a
|
||||
* master key (typically the EncryptIDKeyManager's AES-256-GCM key material).
|
||||
*
|
||||
* Usage:
|
||||
* const crypto = new DocCrypto();
|
||||
* await crypto.init(masterKey); // from EncryptIDKeyManager PRF
|
||||
* const spaceKey = await crypto.deriveSpaceKey('my-space');
|
||||
* const docKey = await crypto.deriveDocKey(spaceKey, 'notes:items');
|
||||
* const blob = await crypto.encrypt(docKey, automergeBytes);
|
||||
* const plain = await crypto.decrypt(docKey, blob);
|
||||
*/
|
||||
export class DocCrypto {
|
||||
#masterKeyMaterial: CryptoKey | null = null;
|
||||
|
||||
/**
|
||||
* Initialize from a master key. Accepts either:
|
||||
* - A CryptoKey with HKDF usage (from EncryptIDKeyManager.initFromPRF)
|
||||
* - Raw key bytes (Uint8Array / ArrayBuffer) that will be imported as HKDF material
|
||||
*/
|
||||
async init(masterKey: CryptoKey | Uint8Array | ArrayBuffer): Promise<void> {
|
||||
if (masterKey instanceof CryptoKey) {
|
||||
// If the key already supports deriveBits/deriveKey, use it directly
|
||||
if (masterKey.algorithm.name === 'HKDF') {
|
||||
this.#masterKeyMaterial = masterKey;
|
||||
} else {
|
||||
// It's an AES-GCM key — export raw bits and re-import as HKDF
|
||||
const raw = await crypto.subtle.exportKey('raw', masterKey);
|
||||
this.#masterKeyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
raw as ArrayBuffer,
|
||||
{ name: 'HKDF' },
|
||||
false,
|
||||
['deriveKey', 'deriveBits']
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Raw bytes → import as HKDF
|
||||
const buf = masterKey instanceof Uint8Array ? masterKey.buffer as ArrayBuffer : masterKey;
|
||||
this.#masterKeyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
buf,
|
||||
{ name: 'HKDF' },
|
||||
false,
|
||||
['deriveKey', 'deriveBits']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize directly from WebAuthn PRF output (convenience shortcut).
|
||||
*/
|
||||
async initFromPRF(prfOutput: ArrayBuffer): Promise<void> {
|
||||
await this.init(new Uint8Array(prfOutput));
|
||||
}
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return this.#masterKeyMaterial !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a space-level HKDF key.
|
||||
* info = "rspace:{spaceId}"
|
||||
*/
|
||||
async deriveSpaceKey(spaceId: string): Promise<CryptoKey> {
|
||||
this.#assertInit();
|
||||
|
||||
// Derive 256 bits of key material for the space
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: encoder.encode('rspace-space-key-v1'),
|
||||
info: encoder.encode(`rspace:${spaceId}`),
|
||||
},
|
||||
this.#masterKeyMaterial!,
|
||||
256
|
||||
);
|
||||
|
||||
// Re-import as HKDF for further derivation (space → doc)
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
bits,
|
||||
{ name: 'HKDF' },
|
||||
false,
|
||||
['deriveKey', 'deriveBits']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a document-level AES-256-GCM key from a space key.
|
||||
* info = "rspace:{spaceId}:{docId}"
|
||||
*/
|
||||
async deriveDocKey(spaceKey: CryptoKey, docId: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: encoder.encode('rspace-doc-key-v1'),
|
||||
info: encoder.encode(`doc:${docId}`),
|
||||
},
|
||||
spaceKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false, // non-extractable
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data with a document key.
|
||||
* Returns ciphertext + 12-byte random nonce.
|
||||
*/
|
||||
async encrypt(docKey: CryptoKey, data: Uint8Array): Promise<EncryptedBlob> {
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: nonce },
|
||||
docKey,
|
||||
data.buffer as ArrayBuffer
|
||||
);
|
||||
return {
|
||||
ciphertext: new Uint8Array(ciphertext),
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted blob with a document key.
|
||||
*/
|
||||
async decrypt(docKey: CryptoKey, blob: EncryptedBlob): Promise<Uint8Array> {
|
||||
const iv = new Uint8Array(blob.nonce) as unknown as ArrayBuffer;
|
||||
const ct = new Uint8Array(blob.ciphertext) as unknown as ArrayBuffer;
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
docKey,
|
||||
ct
|
||||
);
|
||||
return new Uint8Array(plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: derive doc key directly from master key (space + doc in one call).
|
||||
*/
|
||||
async deriveDocKeyDirect(spaceId: string, docId: string): Promise<CryptoKey> {
|
||||
const spaceKey = await this.deriveSpaceKey(spaceId);
|
||||
return this.deriveDocKey(spaceKey, docId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an EncryptedBlob for storage (nonce prepended to ciphertext).
|
||||
* Format: [12-byte nonce][ciphertext...]
|
||||
*/
|
||||
static pack(blob: EncryptedBlob): Uint8Array {
|
||||
const packed = new Uint8Array(12 + blob.ciphertext.length);
|
||||
packed.set(blob.nonce, 0);
|
||||
packed.set(blob.ciphertext, 12);
|
||||
return packed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a packed blob.
|
||||
*/
|
||||
static unpack(packed: Uint8Array): EncryptedBlob {
|
||||
return {
|
||||
nonce: packed.slice(0, 12),
|
||||
ciphertext: packed.slice(12),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear master key material from memory.
|
||||
*/
|
||||
clear(): void {
|
||||
this.#masterKeyMaterial = null;
|
||||
}
|
||||
|
||||
#assertInit(): void {
|
||||
if (!this.#masterKeyMaterial) {
|
||||
throw new Error('DocCrypto not initialized — call init() first');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
/**
|
||||
* Layer 2: Document — Automerge document management with typed schemas.
|
||||
*
|
||||
* DocumentId format: "{space}:{module}:{collection}" or "{space}:{module}:{collection}:{itemId}"
|
||||
*
|
||||
* Granularity principle: one document per "unit of collaboration."
|
||||
* Binary blobs (PDFs, .splat, images) stay in blob storage with metadata refs in Automerge.
|
||||
*/
|
||||
|
||||
import * as Automerge from '@automerge/automerge';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Document ID — hierarchical, colon-separated.
|
||||
* 3-part: space-level collection (e.g. "demo:notes:items")
|
||||
* 4-part: item-level doc (e.g. "demo:work:boards:board-1")
|
||||
*/
|
||||
export type DocumentId =
|
||||
| `${string}:${string}:${string}`
|
||||
| `${string}:${string}:${string}:${string}`;
|
||||
|
||||
/**
|
||||
* Parse a DocumentId into its components.
|
||||
*/
|
||||
export interface ParsedDocumentId {
|
||||
space: string;
|
||||
module: string;
|
||||
collection: string;
|
||||
itemId?: string;
|
||||
}
|
||||
|
||||
export function parseDocumentId(id: DocumentId): ParsedDocumentId {
|
||||
const parts = id.split(':');
|
||||
if (parts.length < 3 || parts.length > 4) {
|
||||
throw new Error(`Invalid DocumentId: "${id}" — expected 3 or 4 colon-separated parts`);
|
||||
}
|
||||
return {
|
||||
space: parts[0],
|
||||
module: parts[1],
|
||||
collection: parts[2],
|
||||
itemId: parts[3],
|
||||
};
|
||||
}
|
||||
|
||||
export function makeDocumentId(
|
||||
space: string,
|
||||
module: string,
|
||||
collection: string,
|
||||
itemId?: string
|
||||
): DocumentId {
|
||||
if (itemId) {
|
||||
return `${space}:${module}:${collection}:${itemId}` as DocumentId;
|
||||
}
|
||||
return `${space}:${module}:${collection}` as DocumentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema definition for an Automerge document.
|
||||
* Each module/collection pair defines a schema with version + initializer.
|
||||
*/
|
||||
export interface DocSchema<T> {
|
||||
module: string;
|
||||
collection: string;
|
||||
version: number;
|
||||
/** Create the initial document state */
|
||||
init: () => T;
|
||||
/** Migrate from an older version (called when loaded doc version < schema version) */
|
||||
migrate?: (doc: T, fromVersion: number) => T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata stored alongside each document.
|
||||
*/
|
||||
export interface DocMeta {
|
||||
docId: DocumentId;
|
||||
module: string;
|
||||
collection: string;
|
||||
version: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DOCUMENT MANAGER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* DocumentManager — manages multiple Automerge documents in memory.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Open/create documents with typed schemas
|
||||
* - Track open documents and their metadata
|
||||
* - Apply changes with Automerge.change()
|
||||
* - List documents by space/module
|
||||
*
|
||||
* Does NOT handle persistence (that's Layer 3) or sync (Layer 4).
|
||||
*/
|
||||
export class DocumentManager {
|
||||
#docs = new Map<DocumentId, Automerge.Doc<any>>();
|
||||
#meta = new Map<DocumentId, DocMeta>();
|
||||
#schemas = new Map<string, DocSchema<any>>();
|
||||
#changeListeners = new Map<DocumentId, Set<(doc: any) => void>>();
|
||||
|
||||
/**
|
||||
* Register a schema so documents can be opened with type safety.
|
||||
*/
|
||||
registerSchema<T>(schema: DocSchema<T>): void {
|
||||
const key = `${schema.module}:${schema.collection}`;
|
||||
this.#schemas.set(key, schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the registered schema for a module/collection.
|
||||
*/
|
||||
getSchema(module: string, collection: string): DocSchema<any> | undefined {
|
||||
return this.#schemas.get(`${module}:${collection}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (or create) a document. If already open, returns the cached instance.
|
||||
* If binary data is provided, loads from that; otherwise creates from schema.init().
|
||||
*/
|
||||
open<T extends Record<string, any>>(
|
||||
id: DocumentId,
|
||||
schema: DocSchema<T>,
|
||||
binary?: Uint8Array
|
||||
): Automerge.Doc<T> {
|
||||
// Return cached if already open
|
||||
const existing = this.#docs.get(id);
|
||||
if (existing) return existing as Automerge.Doc<T>;
|
||||
|
||||
let doc: Automerge.Doc<T>;
|
||||
const now = Date.now();
|
||||
|
||||
if (binary) {
|
||||
// Load from persisted binary
|
||||
doc = Automerge.load<T>(binary);
|
||||
|
||||
// Check if migration is needed
|
||||
const meta = this.#meta.get(id);
|
||||
if (meta && meta.version < schema.version && schema.migrate) {
|
||||
doc = Automerge.change(doc, `Migrate ${id} to v${schema.version}`, (d) => {
|
||||
schema.migrate!(d, meta.version);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create fresh document from schema
|
||||
doc = Automerge.init<T>();
|
||||
doc = Automerge.change(doc, `Initialize ${id}`, (d) => {
|
||||
Object.assign(d, schema.init());
|
||||
});
|
||||
}
|
||||
|
||||
this.#docs.set(id, doc);
|
||||
this.#meta.set(id, {
|
||||
docId: id,
|
||||
module: schema.module,
|
||||
collection: schema.collection,
|
||||
version: schema.version,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
// Register schema if not already
|
||||
this.registerSchema(schema);
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an already-open document.
|
||||
*/
|
||||
get<T>(id: DocumentId): Automerge.Doc<T> | undefined {
|
||||
return this.#docs.get(id) as Automerge.Doc<T> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a change to a document (Automerge.change wrapper).
|
||||
* Notifies change listeners.
|
||||
*/
|
||||
change<T>(
|
||||
id: DocumentId,
|
||||
message: string,
|
||||
fn: (doc: T) => void
|
||||
): Automerge.Doc<T> {
|
||||
const doc = this.#docs.get(id);
|
||||
if (!doc) {
|
||||
throw new Error(`Document not open: ${id}`);
|
||||
}
|
||||
|
||||
const updated = Automerge.change(doc, message, fn as any);
|
||||
this.#docs.set(id, updated);
|
||||
|
||||
// Update metadata timestamp
|
||||
const meta = this.#meta.get(id);
|
||||
if (meta) {
|
||||
meta.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
// Notify listeners
|
||||
this.#notifyChange(id, updated);
|
||||
|
||||
return updated as Automerge.Doc<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a document (e.g. after receiving sync data).
|
||||
*/
|
||||
set<T>(id: DocumentId, doc: Automerge.Doc<T>): void {
|
||||
this.#docs.set(id, doc);
|
||||
const meta = this.#meta.get(id);
|
||||
if (meta) {
|
||||
meta.updatedAt = Date.now();
|
||||
}
|
||||
this.#notifyChange(id, doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a document — remove from in-memory cache.
|
||||
*/
|
||||
close(id: DocumentId): void {
|
||||
this.#docs.delete(id);
|
||||
this.#meta.delete(id);
|
||||
this.#changeListeners.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get document binary for persistence.
|
||||
*/
|
||||
save(id: DocumentId): Uint8Array | null {
|
||||
const doc = this.#docs.get(id);
|
||||
if (!doc) return null;
|
||||
return Automerge.save(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a document.
|
||||
*/
|
||||
getMeta(id: DocumentId): DocMeta | undefined {
|
||||
return this.#meta.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all open document IDs for a given space and module.
|
||||
*/
|
||||
list(space: string, module?: string): DocumentId[] {
|
||||
const results: DocumentId[] = [];
|
||||
for (const [id, meta] of this.#meta) {
|
||||
const parsed = parseDocumentId(id);
|
||||
if (parsed.space !== space) continue;
|
||||
if (module && parsed.module !== module) continue;
|
||||
results.push(id);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all open document IDs.
|
||||
*/
|
||||
listAll(): DocumentId[] {
|
||||
return Array.from(this.#docs.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to changes on a document.
|
||||
*/
|
||||
onChange<T>(id: DocumentId, cb: (doc: Automerge.Doc<T>) => void): () => void {
|
||||
let listeners = this.#changeListeners.get(id);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
this.#changeListeners.set(id, listeners);
|
||||
}
|
||||
listeners.add(cb);
|
||||
return () => { listeners!.delete(cb); };
|
||||
}
|
||||
|
||||
#notifyChange(id: DocumentId, doc: any): void {
|
||||
const listeners = this.#changeListeners.get(id);
|
||||
if (!listeners) return;
|
||||
for (const cb of listeners) {
|
||||
try { cb(doc); } catch (e) {
|
||||
console.error(`[DocumentManager] Change listener error for ${id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Local-First Data Infrastructure for rSpace
|
||||
*
|
||||
* 7-layer architecture:
|
||||
* 1. Crypto — PRF key derivation, AES-256-GCM per-document encryption
|
||||
* 2. Document — Automerge documents with typed schemas
|
||||
* 3. Storage — Encrypted multi-document IndexedDB
|
||||
* 4. Sync — Multi-doc WebSocket sync (client-side)
|
||||
* 5. Compute — Deterministic transforms (local or server-delegated)
|
||||
* 6. Query — Materialized views + full-text search
|
||||
* 7. Application (module-specific, not in this package)
|
||||
*
|
||||
* Plus: Memory Card interchange format for cross-module data exchange.
|
||||
*/
|
||||
|
||||
// Layer 1: Crypto
|
||||
export { DocCrypto, type EncryptedBlob } from './crypto';
|
||||
|
||||
// Layer 2: Document
|
||||
export {
|
||||
type DocumentId,
|
||||
type ParsedDocumentId,
|
||||
type DocSchema,
|
||||
type DocMeta,
|
||||
parseDocumentId,
|
||||
makeDocumentId,
|
||||
DocumentManager,
|
||||
} from './document';
|
||||
|
||||
// Layer 3: Storage
|
||||
export { EncryptedDocStore } from './storage';
|
||||
|
||||
// Layer 4: Sync (client)
|
||||
export {
|
||||
DocSyncManager,
|
||||
type DocSyncManagerOptions,
|
||||
type SyncMessage,
|
||||
type SubscribeMessage,
|
||||
type UnsubscribeMessage,
|
||||
type AwarenessMessage,
|
||||
type WireMessage,
|
||||
} from './sync';
|
||||
|
||||
// Layer 5: Compute
|
||||
export {
|
||||
type Transform,
|
||||
ComputeEngine,
|
||||
type ComputeEngineOptions,
|
||||
localTransform,
|
||||
serverTransform,
|
||||
} from './compute';
|
||||
|
||||
// Layer 6: Query
|
||||
export {
|
||||
type MaterializedView,
|
||||
type SearchResult,
|
||||
ViewEngine,
|
||||
LocalSearchEngine,
|
||||
} from './query';
|
||||
|
||||
// Memory Card interchange
|
||||
export {
|
||||
type MemoryCard,
|
||||
type MemoryCardType,
|
||||
type CardExporter,
|
||||
createCard,
|
||||
filterByType,
|
||||
filterByTag,
|
||||
filterByModule,
|
||||
sortByNewest,
|
||||
searchCards,
|
||||
registerExporter,
|
||||
getExporter,
|
||||
getAllExporters,
|
||||
} from './memory-card';
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Memory Card — Cross-module data interchange format.
|
||||
*
|
||||
* Any module can export items as Memory Cards; any module can import/reference them.
|
||||
* Think of it as a universal "clip" format for data flowing between rSpace modules.
|
||||
*/
|
||||
|
||||
import type { DocumentId } from './document';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Core Memory Card — the universal interchange unit.
|
||||
*/
|
||||
export interface MemoryCard {
|
||||
/** Unique ID (UUID v4 or module-specific) */
|
||||
id: string;
|
||||
/** Semantic type */
|
||||
type: MemoryCardType | string;
|
||||
/** Human-readable title */
|
||||
title: string;
|
||||
/** Optional body text (markdown) */
|
||||
body?: string;
|
||||
/** Source provenance */
|
||||
source: {
|
||||
module: string;
|
||||
docId: DocumentId;
|
||||
itemId?: string;
|
||||
};
|
||||
/** Freeform tags for filtering/grouping */
|
||||
tags: string[];
|
||||
/** Unix timestamp (ms) */
|
||||
createdAt: number;
|
||||
/** Module-specific structured data */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Well-known card types. Modules can extend with custom strings.
|
||||
*/
|
||||
export type MemoryCardType =
|
||||
| 'note'
|
||||
| 'task'
|
||||
| 'event'
|
||||
| 'link'
|
||||
| 'file'
|
||||
| 'vote'
|
||||
| 'transaction'
|
||||
| 'trip'
|
||||
| 'contact'
|
||||
| 'shape'
|
||||
| 'book'
|
||||
| 'product'
|
||||
| 'post';
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a Memory Card with defaults.
|
||||
*/
|
||||
export function createCard(
|
||||
fields: Pick<MemoryCard, 'type' | 'title' | 'source'> & Partial<MemoryCard>
|
||||
): MemoryCard {
|
||||
return {
|
||||
id: fields.id ?? generateId(),
|
||||
type: fields.type,
|
||||
title: fields.title,
|
||||
body: fields.body,
|
||||
source: fields.source,
|
||||
tags: fields.tags ?? [],
|
||||
createdAt: fields.createdAt ?? Date.now(),
|
||||
data: fields.data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter cards by type.
|
||||
*/
|
||||
export function filterByType(cards: MemoryCard[], type: string): MemoryCard[] {
|
||||
return cards.filter((c) => c.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter cards by tag (any match).
|
||||
*/
|
||||
export function filterByTag(cards: MemoryCard[], tag: string): MemoryCard[] {
|
||||
return cards.filter((c) => c.tags.includes(tag));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter cards by source module.
|
||||
*/
|
||||
export function filterByModule(cards: MemoryCard[], module: string): MemoryCard[] {
|
||||
return cards.filter((c) => c.source.module === module);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort cards by creation time (newest first).
|
||||
*/
|
||||
export function sortByNewest(cards: MemoryCard[]): MemoryCard[] {
|
||||
return [...cards].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search cards by title/body text (case-insensitive substring match).
|
||||
*/
|
||||
export function searchCards(cards: MemoryCard[], query: string): MemoryCard[] {
|
||||
const q = query.toLowerCase();
|
||||
return cards.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(q) ||
|
||||
(c.body && c.body.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CARD EXPORTER INTERFACE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Modules implement this to export their data as Memory Cards.
|
||||
*/
|
||||
export interface CardExporter {
|
||||
module: string;
|
||||
/** Export all items from a document as cards */
|
||||
exportCards(docId: DocumentId, doc: any): MemoryCard[];
|
||||
/** Export a single item as a card */
|
||||
exportCard?(docId: DocumentId, doc: any, itemId: string): MemoryCard | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of card exporters.
|
||||
*/
|
||||
const exporters = new Map<string, CardExporter>();
|
||||
|
||||
export function registerExporter(exporter: CardExporter): void {
|
||||
exporters.set(exporter.module, exporter);
|
||||
}
|
||||
|
||||
export function getExporter(module: string): CardExporter | undefined {
|
||||
return exporters.get(module);
|
||||
}
|
||||
|
||||
export function getAllExporters(): CardExporter[] {
|
||||
return Array.from(exporters.values());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
function generateId(): string {
|
||||
// crypto.randomUUID() is available in all modern browsers and Bun
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
/**
|
||||
* Layer 6: Query — Materialized views + full-text search over Automerge documents.
|
||||
*
|
||||
* All computation is client-side. Views are automatically recomputed when their
|
||||
* source documents change (via DocumentManager change subscriptions).
|
||||
*/
|
||||
|
||||
import type { DocumentId } from './document';
|
||||
import type { DocumentManager } from './document';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A materialized view: takes a document and projects it into a view shape.
|
||||
* Views are cached and recomputed lazily on document change.
|
||||
*/
|
||||
export interface MaterializedView<T = any, V = any> {
|
||||
/** Unique view identifier */
|
||||
id: string;
|
||||
/** Which document this view is derived from */
|
||||
docId: DocumentId;
|
||||
/** Project the document into the view */
|
||||
compute(doc: T): V;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
docId: DocumentId;
|
||||
field: string;
|
||||
/** The matched text snippet */
|
||||
snippet: string;
|
||||
/** Relevance score (higher = better match) */
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface IndexEntry {
|
||||
docId: DocumentId;
|
||||
field: string;
|
||||
text: string;
|
||||
/** Lowercase tokens for matching */
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ViewEngine
|
||||
// ============================================================================
|
||||
|
||||
export class ViewEngine {
|
||||
#views = new Map<string, MaterializedView>();
|
||||
#cache = new Map<string, any>();
|
||||
#documents: DocumentManager;
|
||||
#unsubs = new Map<string, () => void>();
|
||||
#subscribers = new Map<string, Set<(v: any) => void>>();
|
||||
|
||||
constructor(documents: DocumentManager) {
|
||||
this.#documents = documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a materialized view. Immediately computes it if the source doc is open.
|
||||
*/
|
||||
register<T, V>(view: MaterializedView<T, V>): void {
|
||||
this.#views.set(view.id, view);
|
||||
|
||||
// Compute initial value if doc is available
|
||||
const doc = this.#documents.get<T>(view.docId);
|
||||
if (doc) {
|
||||
this.#recompute(view);
|
||||
}
|
||||
|
||||
// Subscribe to document changes
|
||||
const unsub = this.#documents.onChange<T>(view.docId, () => {
|
||||
this.#recompute(view);
|
||||
});
|
||||
this.#unsubs.set(view.id, unsub);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a view.
|
||||
*/
|
||||
unregister(viewId: string): void {
|
||||
this.#views.delete(viewId);
|
||||
this.#cache.delete(viewId);
|
||||
this.#subscribers.delete(viewId);
|
||||
const unsub = this.#unsubs.get(viewId);
|
||||
if (unsub) { unsub(); this.#unsubs.delete(viewId); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current value of a view (cached).
|
||||
*/
|
||||
get<V>(viewId: string): V | undefined {
|
||||
return this.#cache.get(viewId) as V | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to view changes. Returns unsubscribe function.
|
||||
*/
|
||||
subscribe<V>(viewId: string, cb: (v: V) => void): () => void {
|
||||
let set = this.#subscribers.get(viewId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.#subscribers.set(viewId, set);
|
||||
}
|
||||
set.add(cb);
|
||||
|
||||
// Immediately call with current value if available
|
||||
const current = this.#cache.get(viewId);
|
||||
if (current !== undefined) {
|
||||
cb(current as V);
|
||||
}
|
||||
|
||||
return () => { set!.delete(cb); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Force recompute a view.
|
||||
*/
|
||||
refresh(viewId: string): void {
|
||||
const view = this.#views.get(viewId);
|
||||
if (view) this.#recompute(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all views and clean up subscriptions.
|
||||
*/
|
||||
destroy(): void {
|
||||
for (const unsub of this.#unsubs.values()) {
|
||||
unsub();
|
||||
}
|
||||
this.#views.clear();
|
||||
this.#cache.clear();
|
||||
this.#unsubs.clear();
|
||||
this.#subscribers.clear();
|
||||
}
|
||||
|
||||
#recompute(view: MaterializedView): void {
|
||||
const doc = this.#documents.get(view.docId);
|
||||
if (!doc) return;
|
||||
|
||||
try {
|
||||
const value = view.compute(doc);
|
||||
this.#cache.set(view.id, value);
|
||||
|
||||
const subs = this.#subscribers.get(view.id);
|
||||
if (subs) {
|
||||
for (const cb of subs) {
|
||||
try { cb(value); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[ViewEngine] Error computing view "${view.id}":`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LocalSearchEngine
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Client-side full-text search over Automerge documents.
|
||||
* Simple token-based matching — not a full inverted index, but fast enough
|
||||
* for the expected data sizes (hundreds, not millions of documents).
|
||||
*/
|
||||
export class LocalSearchEngine {
|
||||
#index: IndexEntry[] = [];
|
||||
#documents: DocumentManager;
|
||||
#indexedDocs = new Set<string>(); // "docId:field" set for dedup
|
||||
|
||||
constructor(documents: DocumentManager) {
|
||||
this.#documents = documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index specific fields of a document for searching.
|
||||
* Call this when a document is opened or changes.
|
||||
*/
|
||||
index(docId: DocumentId, fields: string[]): void {
|
||||
const doc = this.#documents.get(docId);
|
||||
if (!doc) return;
|
||||
|
||||
for (const field of fields) {
|
||||
const key = `${docId}:${field}`;
|
||||
|
||||
// Remove old entries for this doc+field
|
||||
this.#index = this.#index.filter((e) => !(e.docId === docId && e.field === field));
|
||||
this.#indexedDocs.delete(key);
|
||||
|
||||
const text = extractText(doc, field);
|
||||
if (!text) continue;
|
||||
|
||||
this.#index.push({
|
||||
docId,
|
||||
field,
|
||||
text,
|
||||
tokens: tokenize(text),
|
||||
});
|
||||
this.#indexedDocs.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index all text fields from a map/object structure.
|
||||
* Walks one level of keys, indexes any string values.
|
||||
*/
|
||||
indexMap(docId: DocumentId, mapField: string): void {
|
||||
const doc = this.#documents.get(docId);
|
||||
if (!doc) return;
|
||||
|
||||
const map = (doc as any)[mapField];
|
||||
if (!map || typeof map !== 'object') return;
|
||||
|
||||
for (const [itemId, item] of Object.entries(map)) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
for (const [key, value] of Object.entries(item as Record<string, unknown>)) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const fullField = `${mapField}.${itemId}.${key}`;
|
||||
const compositeKey = `${docId}:${fullField}`;
|
||||
|
||||
this.#index = this.#index.filter((e) => !(e.docId === docId && e.field === fullField));
|
||||
this.#indexedDocs.delete(compositeKey);
|
||||
|
||||
this.#index.push({
|
||||
docId,
|
||||
field: fullField,
|
||||
text: value,
|
||||
tokens: tokenize(value),
|
||||
});
|
||||
this.#indexedDocs.add(compositeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all index entries for a document.
|
||||
*/
|
||||
removeDoc(docId: DocumentId): void {
|
||||
this.#index = this.#index.filter((e) => e.docId !== docId);
|
||||
// Clean up indexedDocs set
|
||||
for (const key of this.#indexedDocs) {
|
||||
if (key.startsWith(`${docId}:`)) {
|
||||
this.#indexedDocs.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search across all indexed documents.
|
||||
*/
|
||||
search(query: string, opts?: { module?: string; maxResults?: number }): SearchResult[] {
|
||||
const queryTokens = tokenize(query);
|
||||
if (queryTokens.length === 0) return [];
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
const moduleFilter = opts?.module;
|
||||
const maxResults = opts?.maxResults ?? 50;
|
||||
|
||||
for (const entry of this.#index) {
|
||||
// Optional module filter
|
||||
if (moduleFilter) {
|
||||
const parts = entry.docId.split(':');
|
||||
if (parts[1] !== moduleFilter) continue;
|
||||
}
|
||||
|
||||
const score = computeScore(queryTokens, entry.tokens);
|
||||
if (score > 0) {
|
||||
results.push({
|
||||
docId: entry.docId,
|
||||
field: entry.field,
|
||||
snippet: createSnippet(entry.text, query),
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
return results.slice(0, maxResults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire index.
|
||||
*/
|
||||
clear(): void {
|
||||
this.#index = [];
|
||||
this.#indexedDocs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length > 1);
|
||||
}
|
||||
|
||||
function extractText(doc: any, fieldPath: string): string | null {
|
||||
const parts = fieldPath.split('.');
|
||||
let value: any = doc;
|
||||
for (const part of parts) {
|
||||
if (value == null || typeof value !== 'object') return null;
|
||||
value = value[part];
|
||||
}
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function computeScore(queryTokens: string[], docTokens: string[]): number {
|
||||
let matches = 0;
|
||||
for (const qt of queryTokens) {
|
||||
for (const dt of docTokens) {
|
||||
if (dt.includes(qt)) {
|
||||
matches++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Score: fraction of query tokens matched
|
||||
return matches / queryTokens.length;
|
||||
}
|
||||
|
||||
function createSnippet(text: string, query: string, contextChars = 60): string {
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const idx = lowerText.indexOf(lowerQuery);
|
||||
|
||||
if (idx === -1) {
|
||||
// No exact match; return beginning of text
|
||||
return text.length > contextChars * 2
|
||||
? text.slice(0, contextChars * 2) + '...'
|
||||
: text;
|
||||
}
|
||||
|
||||
const start = Math.max(0, idx - contextChars);
|
||||
const end = Math.min(text.length, idx + query.length + contextChars);
|
||||
let snippet = text.slice(start, end);
|
||||
|
||||
if (start > 0) snippet = '...' + snippet;
|
||||
if (end < text.length) snippet = snippet + '...';
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
/**
|
||||
* Layer 3: Storage — Encrypted multi-document IndexedDB store.
|
||||
*
|
||||
* Extends the single-document OfflineStore pattern into a multi-document,
|
||||
* per-document encrypted store. Each document is AES-256-GCM encrypted
|
||||
* at rest using keys derived from the user's passkey (DocCrypto).
|
||||
*
|
||||
* IndexedDB database: "rspace-docs"
|
||||
* Object store "docs": { docId, data (EncryptedBlob packed), updatedAt }
|
||||
* Object store "meta": { docId, module, collection, version, createdAt, updatedAt }
|
||||
* Object store "sync": { key: "{docId}:{peerId}", state: Uint8Array }
|
||||
* Index on "meta" by [module] for listByModule queries
|
||||
*/
|
||||
|
||||
import type { DocumentId } from './document';
|
||||
import { DocCrypto, type EncryptedBlob } from './crypto';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface StoredDoc {
|
||||
docId: string;
|
||||
/** Packed encrypted blob (nonce + ciphertext) — or raw bytes if encryption disabled */
|
||||
data: Uint8Array;
|
||||
updatedAt: number;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
interface StoredMeta {
|
||||
docId: string;
|
||||
module: string;
|
||||
collection: string;
|
||||
version: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface StoredSyncState {
|
||||
key: string; // "{docId}\0{peerId}"
|
||||
state: Uint8Array;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EncryptedDocStore
|
||||
// ============================================================================
|
||||
|
||||
export class EncryptedDocStore {
|
||||
#db: IDBDatabase | null = null;
|
||||
#dbName = 'rspace-docs';
|
||||
#version = 1;
|
||||
#crypto: DocCrypto | null = null;
|
||||
#spaceId: string;
|
||||
|
||||
// Debounce infrastructure (same pattern as OfflineStore)
|
||||
#saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
#pendingSaves = new Map<string, { docId: DocumentId; data: Uint8Array }>();
|
||||
#saveDebounceMs = 2000;
|
||||
|
||||
constructor(spaceId: string, docCrypto?: DocCrypto) {
|
||||
this.#spaceId = spaceId;
|
||||
this.#crypto = docCrypto ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the IndexedDB database. Must be called before any other method.
|
||||
*/
|
||||
async open(): Promise<void> {
|
||||
if (this.#db) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.#dbName, this.#version);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
|
||||
if (!db.objectStoreNames.contains('docs')) {
|
||||
db.createObjectStore('docs', { keyPath: 'docId' });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('meta')) {
|
||||
const metaStore = db.createObjectStore('meta', { keyPath: 'docId' });
|
||||
metaStore.createIndex('by_module', 'module', { unique: false });
|
||||
metaStore.createIndex('by_module_collection', ['module', 'collection'], { unique: false });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('sync')) {
|
||||
db.createObjectStore('sync', { keyPath: 'key' });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.#db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('[EncryptedDocStore] Failed to open IndexedDB:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a document (debounced). Encrypts if DocCrypto is configured.
|
||||
*/
|
||||
save(docId: DocumentId, plaintext: Uint8Array, meta?: { module: string; collection: string; version: number }): void {
|
||||
this.#pendingSaves.set(docId, { docId, data: plaintext });
|
||||
|
||||
const existing = this.#saveTimers.get(docId);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
this.#saveTimers.set(
|
||||
docId,
|
||||
setTimeout(() => {
|
||||
this.#saveTimers.delete(docId);
|
||||
this.#pendingSaves.delete(docId);
|
||||
this.#writeDoc(docId, plaintext, meta).catch((e) => {
|
||||
console.error('[EncryptedDocStore] Failed to save doc:', e);
|
||||
});
|
||||
}, this.#saveDebounceMs)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a document immediately (bypasses debounce). Use before page unload.
|
||||
*/
|
||||
async saveImmediate(docId: DocumentId, plaintext: Uint8Array, meta?: { module: string; collection: string; version: number }): Promise<void> {
|
||||
const existing = this.#saveTimers.get(docId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
this.#saveTimers.delete(docId);
|
||||
}
|
||||
this.#pendingSaves.delete(docId);
|
||||
|
||||
await this.#writeDoc(docId, plaintext, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a document. Decrypts if encrypted.
|
||||
*/
|
||||
async load(docId: DocumentId): Promise<Uint8Array | null> {
|
||||
if (!this.#db) return null;
|
||||
|
||||
const stored = await this.#getDoc(docId);
|
||||
if (!stored) return null;
|
||||
|
||||
if (stored.encrypted && this.#crypto) {
|
||||
const docKey = await this.#crypto.deriveDocKeyDirect(this.#spaceId, docId);
|
||||
const blob = DocCrypto.unpack(stored.data);
|
||||
return this.#crypto.decrypt(docKey, blob);
|
||||
}
|
||||
|
||||
return stored.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document and its metadata.
|
||||
*/
|
||||
async delete(docId: DocumentId): Promise<void> {
|
||||
if (!this.#db) return;
|
||||
|
||||
const tx = this.#db.transaction(['docs', 'meta'], 'readwrite');
|
||||
tx.objectStore('docs').delete(docId);
|
||||
tx.objectStore('meta').delete(docId);
|
||||
|
||||
await this.#txComplete(tx);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all document IDs for a space and module.
|
||||
*/
|
||||
async listByModule(module: string, collection?: string): Promise<DocumentId[]> {
|
||||
if (!this.#db) return [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.#db!.transaction('meta', 'readonly');
|
||||
const store = tx.objectStore('meta');
|
||||
|
||||
let request: IDBRequest;
|
||||
if (collection) {
|
||||
const index = store.index('by_module_collection');
|
||||
request = index.getAllKeys(IDBKeyRange.only([module, collection]));
|
||||
} else {
|
||||
const index = store.index('by_module');
|
||||
request = index.getAllKeys(IDBKeyRange.only(module));
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
// Filter to only docs in this space
|
||||
const all = request.result as string[];
|
||||
const filtered = all.filter((id) => id.startsWith(`${this.#spaceId}:`));
|
||||
resolve(filtered as DocumentId[]);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all stored document IDs.
|
||||
*/
|
||||
async listAll(): Promise<DocumentId[]> {
|
||||
if (!this.#db) return [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.#db!.transaction('meta', 'readonly');
|
||||
const request = tx.objectStore('meta').getAllKeys();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const all = request.result as string[];
|
||||
const filtered = all.filter((id) => id.startsWith(`${this.#spaceId}:`));
|
||||
resolve(filtered as DocumentId[]);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sync state for a (docId, peerId) pair.
|
||||
*/
|
||||
async saveSyncState(docId: DocumentId, peerId: string, state: Uint8Array): Promise<void> {
|
||||
if (!this.#db) return;
|
||||
|
||||
const entry: StoredSyncState = {
|
||||
key: `${docId}\0${peerId}`,
|
||||
state,
|
||||
};
|
||||
|
||||
const tx = this.#db.transaction('sync', 'readwrite');
|
||||
tx.objectStore('sync').put(entry);
|
||||
await this.#txComplete(tx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sync state for a (docId, peerId) pair.
|
||||
*/
|
||||
async loadSyncState(docId: DocumentId, peerId: string): Promise<Uint8Array | null> {
|
||||
if (!this.#db) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.#db!.transaction('sync', 'readonly');
|
||||
const request = tx.objectStore('sync').get(`${docId}\0${peerId}`);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const entry = request.result as StoredSyncState | undefined;
|
||||
resolve(entry?.state ?? null);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sync state for a document.
|
||||
*/
|
||||
async clearSyncState(docId: DocumentId): Promise<void> {
|
||||
if (!this.#db) return;
|
||||
|
||||
// Have to iterate since there's no compound index
|
||||
const tx = this.#db.transaction('sync', 'readwrite');
|
||||
const store = tx.objectStore('sync');
|
||||
const request = store.openCursor();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) { resolve(); return; }
|
||||
|
||||
const key = cursor.key as string;
|
||||
if (key.startsWith(`${docId}\0`)) {
|
||||
cursor.delete();
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a document.
|
||||
*/
|
||||
async getMeta(docId: DocumentId): Promise<StoredMeta | null> {
|
||||
if (!this.#db) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.#db!.transaction('meta', 'readonly');
|
||||
const request = tx.objectStore('meta').get(docId);
|
||||
|
||||
request.onsuccess = () => resolve(request.result ?? null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all pending debounced saves immediately.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const [docId, { data }] of this.#pendingSaves) {
|
||||
const timer = this.#saveTimers.get(docId);
|
||||
if (timer) clearTimeout(timer);
|
||||
this.#saveTimers.delete(docId);
|
||||
promises.push(this.#writeDoc(docId as DocumentId, data));
|
||||
}
|
||||
this.#pendingSaves.clear();
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
// ---------- Private helpers ----------
|
||||
|
||||
async #writeDoc(
|
||||
docId: DocumentId,
|
||||
plaintext: Uint8Array,
|
||||
meta?: { module: string; collection: string; version: number }
|
||||
): Promise<void> {
|
||||
if (!this.#db) return;
|
||||
|
||||
const now = Date.now();
|
||||
let data: Uint8Array;
|
||||
let encrypted = false;
|
||||
|
||||
if (this.#crypto?.isInitialized) {
|
||||
const docKey = await this.#crypto.deriveDocKeyDirect(this.#spaceId, docId);
|
||||
const blob = await this.#crypto.encrypt(docKey, plaintext);
|
||||
data = DocCrypto.pack(blob);
|
||||
encrypted = true;
|
||||
} else {
|
||||
data = plaintext;
|
||||
}
|
||||
|
||||
const tx = this.#db.transaction(['docs', 'meta'], 'readwrite');
|
||||
|
||||
const storedDoc: StoredDoc = { docId, data, updatedAt: now, encrypted };
|
||||
tx.objectStore('docs').put(storedDoc);
|
||||
|
||||
if (meta) {
|
||||
const existingMeta = await this.getMeta(docId);
|
||||
const storedMeta: StoredMeta = {
|
||||
docId,
|
||||
module: meta.module,
|
||||
collection: meta.collection,
|
||||
version: meta.version,
|
||||
createdAt: existingMeta?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
tx.objectStore('meta').put(storedMeta);
|
||||
}
|
||||
|
||||
await this.#txComplete(tx);
|
||||
}
|
||||
|
||||
#getDoc(docId: DocumentId): Promise<StoredDoc | null> {
|
||||
if (!this.#db) return Promise.resolve(null);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.#db!.transaction('docs', 'readonly');
|
||||
const request = tx.objectStore('docs').get(docId);
|
||||
|
||||
request.onsuccess = () => resolve(request.result ?? null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
#txComplete(tx: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,489 @@
|
|||
/**
|
||||
* Layer 4: Sync (Client) — Multi-document WebSocket sync.
|
||||
*
|
||||
* Multiplexes sync for multiple Automerge documents over a single
|
||||
* WebSocket connection per space. Handles:
|
||||
* - Subscribe/unsubscribe to individual documents
|
||||
* - Automerge sync protocol per document
|
||||
* - Offline queue (changes made offline replayed on reconnect)
|
||||
* - Presence/awareness per document
|
||||
*
|
||||
* Wire protocol (JSON envelope, binary sync data as number arrays):
|
||||
* { type: 'sync', docId, data: number[] } — Automerge sync message
|
||||
* { type: 'subscribe', docIds: string[] } — Start syncing docs
|
||||
* { type: 'unsubscribe', docIds: string[] } — Stop syncing docs
|
||||
* { type: 'awareness', docId, peer, cursor? } — Presence
|
||||
* { type: 'ping' } / { type: 'pong' } — Keep-alive
|
||||
*/
|
||||
|
||||
import * as Automerge from '@automerge/automerge';
|
||||
import type { DocumentId } from './document';
|
||||
import { DocumentManager } from './document';
|
||||
import { EncryptedDocStore } from './storage';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface SyncMessage {
|
||||
type: 'sync';
|
||||
docId: string;
|
||||
data: number[];
|
||||
}
|
||||
|
||||
export interface SubscribeMessage {
|
||||
type: 'subscribe';
|
||||
docIds: string[];
|
||||
}
|
||||
|
||||
export interface UnsubscribeMessage {
|
||||
type: 'unsubscribe';
|
||||
docIds: string[];
|
||||
}
|
||||
|
||||
export interface AwarenessMessage {
|
||||
type: 'awareness';
|
||||
docId: string;
|
||||
peer: string;
|
||||
cursor?: { x: number; y: number };
|
||||
selection?: string;
|
||||
username?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export type WireMessage =
|
||||
| SyncMessage
|
||||
| SubscribeMessage
|
||||
| UnsubscribeMessage
|
||||
| AwarenessMessage
|
||||
| { type: 'ping' }
|
||||
| { type: 'pong' };
|
||||
|
||||
export interface DocSyncManagerOptions {
|
||||
documents: DocumentManager;
|
||||
store?: EncryptedDocStore;
|
||||
/** Peer ID for this client (defaults to random) */
|
||||
peerId?: string;
|
||||
/** Auto-reconnect with exponential backoff (default: true) */
|
||||
autoReconnect?: boolean;
|
||||
/** Max reconnect delay in ms (default: 30000) */
|
||||
maxReconnectDelay?: number;
|
||||
}
|
||||
|
||||
type DocChangeCallback = (doc: any) => void;
|
||||
type AwarenessCallback = (msg: AwarenessMessage) => void;
|
||||
type ConnectionCallback = () => void;
|
||||
|
||||
// ============================================================================
|
||||
// DocSyncManager (Client)
|
||||
// ============================================================================
|
||||
|
||||
export class DocSyncManager {
|
||||
#documents: DocumentManager;
|
||||
#store: EncryptedDocStore | null;
|
||||
#peerId: string;
|
||||
#ws: WebSocket | null = null;
|
||||
#wsUrl: string | null = null;
|
||||
#space: string | null = null;
|
||||
#autoReconnect: boolean;
|
||||
#maxReconnectDelay: number;
|
||||
#reconnectAttempts = 0;
|
||||
|
||||
// Per-document sync state
|
||||
#syncStates = new Map<DocumentId, Automerge.SyncState>();
|
||||
#subscribedDocs = new Set<DocumentId>();
|
||||
|
||||
// Event listeners
|
||||
#changeListeners = new Map<DocumentId, Set<DocChangeCallback>>();
|
||||
#awarenessListeners = new Map<DocumentId, Set<AwarenessCallback>>();
|
||||
#connectListeners = new Set<ConnectionCallback>();
|
||||
#disconnectListeners = new Set<ConnectionCallback>();
|
||||
|
||||
// Save debounce
|
||||
#saveTimers = new Map<DocumentId, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Keep-alive
|
||||
#pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(opts: DocSyncManagerOptions) {
|
||||
this.#documents = opts.documents;
|
||||
this.#store = opts.store ?? null;
|
||||
this.#peerId = opts.peerId ?? generatePeerId();
|
||||
this.#autoReconnect = opts.autoReconnect ?? true;
|
||||
this.#maxReconnectDelay = opts.maxReconnectDelay ?? 30000;
|
||||
}
|
||||
|
||||
get peerId(): string { return this.#peerId; }
|
||||
get isConnected(): boolean { return this.#ws?.readyState === WebSocket.OPEN; }
|
||||
|
||||
/**
|
||||
* Connect to the sync server for a space.
|
||||
*/
|
||||
async connect(wsUrl: string, space: string): Promise<void> {
|
||||
this.#wsUrl = wsUrl;
|
||||
this.#space = space;
|
||||
|
||||
if (this.#ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#ws = new WebSocket(wsUrl);
|
||||
this.#ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.#ws.onopen = () => {
|
||||
this.#reconnectAttempts = 0;
|
||||
|
||||
// Subscribe to all currently tracked docs
|
||||
if (this.#subscribedDocs.size > 0) {
|
||||
this.#send({
|
||||
type: 'subscribe',
|
||||
docIds: Array.from(this.#subscribedDocs),
|
||||
});
|
||||
|
||||
// Initiate sync for each document
|
||||
for (const docId of this.#subscribedDocs) {
|
||||
this.#sendSyncMessage(docId);
|
||||
}
|
||||
}
|
||||
|
||||
// Start keep-alive
|
||||
this.#startPing();
|
||||
|
||||
for (const cb of this.#connectListeners) {
|
||||
try { cb(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.#ws.onmessage = (event) => {
|
||||
this.#handleMessage(event.data);
|
||||
};
|
||||
|
||||
this.#ws.onclose = () => {
|
||||
this.#stopPing();
|
||||
for (const cb of this.#disconnectListeners) {
|
||||
try { cb(); } catch { /* ignore */ }
|
||||
}
|
||||
this.#attemptReconnect();
|
||||
};
|
||||
|
||||
this.#ws.onerror = () => {
|
||||
// onclose will fire after onerror
|
||||
if (this.#ws?.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to sync for one or more documents.
|
||||
*/
|
||||
async subscribe(docIds: DocumentId[]): Promise<void> {
|
||||
const newIds: string[] = [];
|
||||
|
||||
for (const id of docIds) {
|
||||
if (!this.#subscribedDocs.has(id)) {
|
||||
this.#subscribedDocs.add(id);
|
||||
newIds.push(id);
|
||||
|
||||
// Initialize sync state from store if available
|
||||
if (this.#store && !this.#syncStates.has(id)) {
|
||||
const saved = await this.#store.loadSyncState(id, this.#peerId);
|
||||
if (saved) {
|
||||
this.#syncStates.set(id, Automerge.decodeSyncState(saved));
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.#syncStates.has(id)) {
|
||||
this.#syncStates.set(id, Automerge.initSyncState());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newIds.length > 0 && this.isConnected) {
|
||||
this.#send({ type: 'subscribe', docIds: newIds });
|
||||
for (const id of newIds) {
|
||||
this.#sendSyncMessage(id as DocumentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from sync for one or more documents.
|
||||
*/
|
||||
unsubscribe(docIds: DocumentId[]): void {
|
||||
const removed: string[] = [];
|
||||
|
||||
for (const id of docIds) {
|
||||
if (this.#subscribedDocs.delete(id)) {
|
||||
removed.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0 && this.isConnected) {
|
||||
this.#send({ type: 'unsubscribe', docIds: removed });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a local change to a document and sync to server.
|
||||
*/
|
||||
change<T>(docId: DocumentId, message: string, fn: (doc: T) => void): void {
|
||||
this.#documents.change<T>(docId, message, fn);
|
||||
this.#sendSyncMessage(docId);
|
||||
this.#scheduleSave(docId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send awareness/presence update for a document.
|
||||
*/
|
||||
sendAwareness(docId: DocumentId, data: Partial<AwarenessMessage>): void {
|
||||
this.#send({
|
||||
type: 'awareness',
|
||||
docId,
|
||||
peer: this.#peerId,
|
||||
...data,
|
||||
} as AwarenessMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for changes on a specific document.
|
||||
*/
|
||||
onChange(docId: DocumentId, cb: DocChangeCallback): () => void {
|
||||
let set = this.#changeListeners.get(docId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.#changeListeners.set(docId, set);
|
||||
}
|
||||
set.add(cb);
|
||||
return () => { set!.delete(cb); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for awareness updates on a specific document.
|
||||
*/
|
||||
onAwareness(docId: DocumentId, cb: AwarenessCallback): () => void {
|
||||
let set = this.#awarenessListeners.get(docId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.#awarenessListeners.set(docId, set);
|
||||
}
|
||||
set.add(cb);
|
||||
return () => { set!.delete(cb); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for connection events.
|
||||
*/
|
||||
onConnect(cb: ConnectionCallback): () => void {
|
||||
this.#connectListeners.add(cb);
|
||||
return () => { this.#connectListeners.delete(cb); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for disconnection events.
|
||||
*/
|
||||
onDisconnect(cb: ConnectionCallback): () => void {
|
||||
this.#disconnectListeners.add(cb);
|
||||
return () => { this.#disconnectListeners.delete(cb); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from server.
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.#autoReconnect = false;
|
||||
this.#stopPing();
|
||||
if (this.#ws) {
|
||||
this.#ws.close();
|
||||
this.#ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all pending saves and sync state to IndexedDB.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const [docId, timer] of this.#saveTimers) {
|
||||
clearTimeout(timer);
|
||||
const doc = this.#documents.get(docId);
|
||||
if (doc && this.#store) {
|
||||
const binary = Automerge.save(doc);
|
||||
const meta = this.#documents.getMeta(docId);
|
||||
promises.push(
|
||||
this.#store.saveImmediate(docId, binary, meta ? {
|
||||
module: meta.module,
|
||||
collection: meta.collection,
|
||||
version: meta.version,
|
||||
} : undefined)
|
||||
);
|
||||
}
|
||||
}
|
||||
this.#saveTimers.clear();
|
||||
|
||||
// Persist all sync states
|
||||
for (const [docId, state] of this.#syncStates) {
|
||||
if (this.#store) {
|
||||
const encoded = Automerge.encodeSyncState(state);
|
||||
promises.push(this.#store.saveSyncState(docId, this.#peerId, encoded));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
// ---------- Private ----------
|
||||
|
||||
#handleMessage(raw: ArrayBuffer | string): void {
|
||||
try {
|
||||
const data = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
|
||||
const msg: WireMessage = JSON.parse(data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'sync':
|
||||
this.#handleSyncMessage(msg as SyncMessage);
|
||||
break;
|
||||
case 'awareness':
|
||||
this.#handleAwareness(msg as AwarenessMessage);
|
||||
break;
|
||||
case 'pong':
|
||||
// Keep-alive acknowledged
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[DocSyncManager] Failed to handle message:', e);
|
||||
}
|
||||
}
|
||||
|
||||
#handleSyncMessage(msg: SyncMessage): void {
|
||||
const docId = msg.docId as DocumentId;
|
||||
const doc = this.#documents.get(docId);
|
||||
if (!doc) return;
|
||||
|
||||
let syncState = this.#syncStates.get(docId) ?? Automerge.initSyncState();
|
||||
const syncMsg = new Uint8Array(msg.data);
|
||||
|
||||
const [newDoc, newSyncState] = Automerge.receiveSyncMessage(
|
||||
doc,
|
||||
syncState,
|
||||
syncMsg
|
||||
);
|
||||
|
||||
this.#documents.set(docId, newDoc);
|
||||
this.#syncStates.set(docId, newSyncState);
|
||||
this.#scheduleSave(docId);
|
||||
|
||||
// Notify change listeners
|
||||
if (newDoc !== doc) {
|
||||
const listeners = this.#changeListeners.get(docId);
|
||||
if (listeners) {
|
||||
for (const cb of listeners) {
|
||||
try { cb(newDoc); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send response sync message if needed
|
||||
this.#sendSyncMessage(docId);
|
||||
}
|
||||
|
||||
#handleAwareness(msg: AwarenessMessage): void {
|
||||
const docId = msg.docId as DocumentId;
|
||||
const listeners = this.#awarenessListeners.get(docId);
|
||||
if (!listeners) return;
|
||||
for (const cb of listeners) {
|
||||
try { cb(msg); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
#sendSyncMessage(docId: DocumentId): void {
|
||||
const doc = this.#documents.get(docId);
|
||||
if (!doc) return;
|
||||
|
||||
let syncState = this.#syncStates.get(docId) ?? Automerge.initSyncState();
|
||||
const [newSyncState, syncMessage] = Automerge.generateSyncMessage(doc, syncState);
|
||||
|
||||
this.#syncStates.set(docId, newSyncState);
|
||||
|
||||
if (syncMessage && this.isConnected) {
|
||||
this.#send({
|
||||
type: 'sync',
|
||||
docId,
|
||||
data: Array.from(syncMessage),
|
||||
});
|
||||
}
|
||||
|
||||
// Persist sync state
|
||||
if (this.#store) {
|
||||
const encoded = Automerge.encodeSyncState(newSyncState);
|
||||
this.#store.saveSyncState(docId, this.#peerId, encoded).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
#send(msg: WireMessage): void {
|
||||
if (this.#ws?.readyState === WebSocket.OPEN) {
|
||||
this.#ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleSave(docId: DocumentId): void {
|
||||
if (!this.#store) return;
|
||||
|
||||
const existing = this.#saveTimers.get(docId);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
this.#saveTimers.set(docId, setTimeout(() => {
|
||||
this.#saveTimers.delete(docId);
|
||||
const doc = this.#documents.get(docId);
|
||||
if (!doc) return;
|
||||
|
||||
const binary = Automerge.save(doc);
|
||||
const meta = this.#documents.getMeta(docId);
|
||||
this.#store!.save(docId, binary, meta ? {
|
||||
module: meta.module,
|
||||
collection: meta.collection,
|
||||
version: meta.version,
|
||||
} : undefined);
|
||||
}, 2000));
|
||||
}
|
||||
|
||||
#attemptReconnect(): void {
|
||||
if (!this.#autoReconnect || !this.#wsUrl || !this.#space) return;
|
||||
|
||||
this.#reconnectAttempts++;
|
||||
const delay = Math.min(
|
||||
1000 * Math.pow(2, this.#reconnectAttempts - 1),
|
||||
this.#maxReconnectDelay
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.#wsUrl && this.#space) {
|
||||
this.connect(this.#wsUrl, this.#space).catch(() => {});
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
#startPing(): void {
|
||||
this.#stopPing();
|
||||
this.#pingInterval = setInterval(() => {
|
||||
this.#send({ type: 'ping' });
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
#stopPing(): void {
|
||||
if (this.#pingInterval) {
|
||||
clearInterval(this.#pingInterval);
|
||||
this.#pingInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generatePeerId(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `peer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
Loading…
Reference in New Issue