UNPKG

@daaku/kombat-firestore

Version:

Kombat storage implemented using Firebase Firestore.

201 lines (182 loc) 5.88 kB
import type { FirebaseAPI, FirebaseConfig } from '@daaku/firebase-rest-api' import { Merkle, Message, Remote, SyncRequest, Timestamp } from '@daaku/kombat' function docToMsg(doc: any): Message { let value: undefined if (doc.document.fields.value) { value = JSON.parse(doc.document.fields.value.stringValue) } return { timestamp: doc.document.fields.timestamp.stringValue, dataset: doc.document.fields.dataset.stringValue, row: doc.document.fields.row.stringValue, column: doc.document.fields.column.stringValue, value, } } export class RemoteFirestore implements Remote { private readonly merkleDocPath: string private readonly config: FirebaseConfig private readonly api: FirebaseAPI private readonly groupID: string constructor({ config, api, groupID, }: { config: FirebaseConfig api: FirebaseAPI groupID: string }) { this.config = config this.api = api this.groupID = groupID this.merkleDocPath = this.config.docPath(`merkle/${this.groupID}`) } private msgDocPath(timestamp: string): string { return this.config.docPath(`message_log/${timestamp}`) } private msgUpdateDoc(msg: Message): any { let value = undefined if (msg.value !== undefined) { value = { stringValue: JSON.stringify(msg.value) } } return { update: { name: this.msgDocPath(msg.timestamp), fields: { groupID: { stringValue: this.groupID }, timestamp: { stringValue: msg.timestamp }, dataset: { stringValue: msg.dataset }, row: { stringValue: msg.row }, column: { stringValue: msg.column }, value, }, }, } } public async sync(req: SyncRequest): Promise<SyncRequest> { // get the existing merkle and check if any of messages are already stored const batchGet = (await this.api('post', ':batchGet', { documents: [ this.merkleDocPath, ...req.messages.map(msg => this.msgDocPath(msg.timestamp)), ], mask: { fieldPaths: ['merkle'] }, })) as any[] // there are 3 merkle's involved, the merkle from the request, the existing // merkle on the firestore side, and the new merkle we need to store on the // firestore side. // calculate the new merkle for the firestore side. const [merkleRaw, ...messages] = batchGet let existingMerkle = new Merkle() let newMerkle = new Merkle() let pendingSend: Message[] = [] if (merkleRaw.missing) { // all messages should be missing, otherwise we're in a corrupt state. if (messages.some(m => m.found)) { throw new Error('corruption: no merkle found, but messages were found') } pendingSend = req.messages } else { existingMerkle = Merkle.fromJSON( JSON.parse(merkleRaw.found.fields.merkle.stringValue), ) newMerkle = existingMerkle.clone() messages.forEach((m, index) => { if (m.missing) { pendingSend.push(req.messages[index]) } }) } // update the merkle with the messages we'll be inserting, if any pendingSend.forEach(m => { newMerkle.insert(Timestamp.fromJSON(m.timestamp)) }) // now collect the writes, if any. this will be updates to the merkle and // new messages to write. const writes = [] // write an updated merkle if we changed it if (existingMerkle.diff(newMerkle)) { const write = { update: { name: this.merkleDocPath, fields: { groupID: { stringValue: this.groupID }, merkle: { stringValue: JSON.stringify(newMerkle) }, }, }, } as any // either update the exact document we just read, or ensure we're writing // a new one. this prevents race conditions. if (merkleRaw.found) { write.currentDocument = { updateTime: merkleRaw.updateTime, } } else { write.currentDocument = { exists: false, } } writes.push(write) } // write all pending messages, if any writes.push(...pendingSend.map(this.msgUpdateDoc.bind(this))) // if we have something to write, then write it. if (writes.length > 0) { await this.api('post', ':commit', { writes }) // TODO: check if the writes went thru, if not retry } let pendingIncoming: Message[] = [] // if there are differences in the merkle after updating firestore, then // fetch pending incoming messages. const diffTime = newMerkle.diff(req.merkle) if (diffTime) { const result = (await this.api('post', ':runQuery', { structuredQuery: { from: [ { collectionId: 'message_log', }, ], where: { compositeFilter: { op: 'AND', filters: [ { fieldFilter: { op: 'EQUAL', field: { fieldPath: 'groupID' }, value: { stringValue: this.groupID, }, }, }, { fieldFilter: { op: 'GREATER_THAN_OR_EQUAL', field: { fieldPath: 'timestamp' }, value: { stringValue: new Date(diffTime).toISOString(), }, }, }, ], }, }, orderBy: [ { field: { fieldPath: 'timestamp' }, direction: 'ASCENDING', }, ], }, })) as any[] pendingIncoming = result.map(docToMsg) } // return pending sync messages return { merkle: newMerkle, messages: pendingIncoming, } } }