202 lines
5.7 KiB
TypeScript
202 lines
5.7 KiB
TypeScript
/**
|
|
* R2 Storage Adapter for Automerge Documents
|
|
*
|
|
* Stores Automerge documents as binary in R2, with support for:
|
|
* - Binary document storage (not JSON)
|
|
* - Chunking for large documents (R2 supports up to 5GB per object)
|
|
* - Atomic updates
|
|
*
|
|
* Document storage format in R2:
|
|
* - rooms/{roomId}/automerge.bin - The Automerge document binary
|
|
* - rooms/{roomId}/metadata.json - Optional metadata (schema version, etc.)
|
|
*/
|
|
|
|
import { Automerge, initializeAutomerge } from './automerge-init'
|
|
|
|
// TLDraw store snapshot type (simplified - actual type is more complex)
|
|
export interface TLStoreSnapshot {
|
|
store: Record<string, any>
|
|
schema?: {
|
|
schemaVersion: number
|
|
storeVersion: number
|
|
[key: string]: any
|
|
}
|
|
}
|
|
|
|
/**
|
|
* R2 Storage for Automerge Documents
|
|
*/
|
|
export class AutomergeR2Storage {
|
|
constructor(private r2: R2Bucket) {}
|
|
|
|
/**
|
|
* Load an Automerge document from R2
|
|
* Returns null if document doesn't exist
|
|
*/
|
|
async loadDocument(roomId: string): Promise<Automerge.Doc<TLStoreSnapshot> | null> {
|
|
await initializeAutomerge()
|
|
|
|
const key = this.getDocumentKey(roomId)
|
|
console.log(`📥 Loading Automerge document from R2: ${key}`)
|
|
|
|
try {
|
|
const object = await this.r2.get(key)
|
|
|
|
if (!object) {
|
|
console.log(`📥 No Automerge document found in R2 for room ${roomId}`)
|
|
return null
|
|
}
|
|
|
|
const binary = await object.arrayBuffer()
|
|
const uint8Array = new Uint8Array(binary)
|
|
|
|
console.log(`📥 Loaded Automerge binary from R2: ${uint8Array.byteLength} bytes`)
|
|
|
|
// Load the Automerge document from binary
|
|
const doc = Automerge.load<TLStoreSnapshot>(uint8Array)
|
|
|
|
const shapeCount = doc.store ?
|
|
Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
|
const recordCount = doc.store ? Object.keys(doc.store).length : 0
|
|
|
|
console.log(`📥 Loaded Automerge document: ${recordCount} records, ${shapeCount} shapes`)
|
|
|
|
return doc
|
|
} catch (error) {
|
|
console.error(`❌ Error loading Automerge document from R2:`, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save an Automerge document to R2
|
|
*/
|
|
async saveDocument(roomId: string, doc: Automerge.Doc<TLStoreSnapshot>): Promise<boolean> {
|
|
await initializeAutomerge()
|
|
|
|
const key = this.getDocumentKey(roomId)
|
|
|
|
try {
|
|
// Serialize the Automerge document to binary
|
|
const binary = Automerge.save(doc)
|
|
|
|
const shapeCount = doc.store ?
|
|
Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
|
const recordCount = doc.store ? Object.keys(doc.store).length : 0
|
|
|
|
console.log(`💾 Saving Automerge document to R2: ${key}`)
|
|
console.log(`💾 Document stats: ${recordCount} records, ${shapeCount} shapes, ${binary.byteLength} bytes`)
|
|
|
|
// Save to R2
|
|
await this.r2.put(key, binary, {
|
|
httpMetadata: {
|
|
contentType: 'application/octet-stream'
|
|
},
|
|
customMetadata: {
|
|
format: 'automerge-binary',
|
|
version: '1',
|
|
recordCount: recordCount.toString(),
|
|
shapeCount: shapeCount.toString(),
|
|
savedAt: new Date().toISOString()
|
|
}
|
|
})
|
|
|
|
console.log(`✅ Successfully saved Automerge document to R2`)
|
|
return true
|
|
} catch (error) {
|
|
console.error(`❌ Error saving Automerge document to R2:`, error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if an Automerge document exists in R2
|
|
*/
|
|
async documentExists(roomId: string): Promise<boolean> {
|
|
const key = this.getDocumentKey(roomId)
|
|
const object = await this.r2.head(key)
|
|
return object !== null
|
|
}
|
|
|
|
/**
|
|
* Delete an Automerge document from R2
|
|
*/
|
|
async deleteDocument(roomId: string): Promise<boolean> {
|
|
const key = this.getDocumentKey(roomId)
|
|
try {
|
|
await this.r2.delete(key)
|
|
console.log(`🗑️ Deleted Automerge document from R2: ${key}`)
|
|
return true
|
|
} catch (error) {
|
|
console.error(`❌ Error deleting Automerge document from R2:`, error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Migrate a JSON document to Automerge format
|
|
* Used for upgrading existing rooms from JSON to Automerge
|
|
*/
|
|
async migrateFromJson(roomId: string, jsonDoc: TLStoreSnapshot): Promise<Automerge.Doc<TLStoreSnapshot> | null> {
|
|
await initializeAutomerge()
|
|
|
|
console.log(`🔄 Migrating room ${roomId} from JSON to Automerge format`)
|
|
|
|
try {
|
|
// Create a new Automerge document
|
|
let doc = Automerge.init<TLStoreSnapshot>()
|
|
|
|
// Apply the JSON data as a change
|
|
doc = Automerge.change(doc, 'Migrate from JSON', (d) => {
|
|
d.store = jsonDoc.store || {}
|
|
if (jsonDoc.schema) {
|
|
d.schema = jsonDoc.schema
|
|
}
|
|
})
|
|
|
|
// Save to R2
|
|
const saved = await this.saveDocument(roomId, doc)
|
|
if (!saved) {
|
|
throw new Error('Failed to save migrated document')
|
|
}
|
|
|
|
console.log(`✅ Successfully migrated room ${roomId} to Automerge format`)
|
|
return doc
|
|
} catch (error) {
|
|
console.error(`❌ Error migrating room ${roomId} to Automerge:`, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a document is in Automerge format
|
|
* (vs old JSON format)
|
|
*/
|
|
async isAutomergeFormat(roomId: string): Promise<boolean> {
|
|
const key = this.getDocumentKey(roomId)
|
|
const object = await this.r2.head(key)
|
|
|
|
if (!object) {
|
|
return false
|
|
}
|
|
|
|
// Check custom metadata for format marker
|
|
const format = object.customMetadata?.format
|
|
return format === 'automerge-binary'
|
|
}
|
|
|
|
/**
|
|
* Get the R2 key for a room's Automerge document
|
|
*/
|
|
private getDocumentKey(roomId: string): string {
|
|
return `rooms/${roomId}/automerge.bin`
|
|
}
|
|
|
|
/**
|
|
* Get the R2 key for a room's legacy JSON document
|
|
*/
|
|
getLegacyJsonKey(roomId: string): string {
|
|
return `rooms/${roomId}`
|
|
}
|
|
}
|