UNPKG

@dwn-protocol/id-sdk

Version:

SDK for accessing the features and capabilities

599 lines (488 loc) 19.5 kB
import type { AbstractBatchOperation, AbstractLevel } from 'abstract-level'; import type { EventsGetReply, GenericMessage, MessagesGetReply, RecordsWriteMessage, } from '@dwn-protocol/id'; import { Level } from 'level'; import { Convert } from '../common/index.js'; import { utils as didUtils } from '../dids/index.js'; import { DataStream } from '@dwn-protocol/id'; import type { IDManagedAgent } from './types/agent.js'; import { webReadableToIsomorphicNodeReadable } from './utils.js'; export interface SyncManager { agent: IDManagedAgent; registerIdentity(options: { did: string }): Promise<void>; startSync(options: { interval: number }): Promise<void>; stopSync(): void; push(): Promise<void>; pull(): Promise<void>; } type LevelDatabase = AbstractLevel<string | Buffer | Uint8Array, string, string>; export type SyncManagerOptions = { agent?: IDManagedAgent; dataPath?: string; db?: LevelDatabase; }; type SyncDirection = 'push' | 'pull'; type SyncState = { did: string; dwnUrl: string; watermark: string | undefined; } type DwnMessage = { message: any; data?: Blob; } type DbBatchOperation = AbstractBatchOperation<LevelDatabase, string, string>; const is2xx = (code: number) => code >= 200 && code <= 299; const is4xx = (code: number) => code >= 400 && code <= 499; export class SyncManagerLevel implements SyncManager { /** * Holds the instance of a `IDManagedAgent` that represents the current * execution context for the `KeyManager`. This agent is utilized * to interact with other agent components. It's vital * to ensure this instance is set to correctly contextualize * operations within the broader agent framework. */ private _agent?: IDManagedAgent; private _db: LevelDatabase; private _syncIntervalId?: ReturnType<typeof setInterval>; constructor(options?: SyncManagerOptions) { let { agent, dataPath = 'data/AGENT/SYNC_STORE', db } = options ?? {}; this._agent = agent; this._db = (db) ? db : new Level(dataPath); } /** * Retrieves the `IDManagedAgent` execution context. * If the `agent` instance proprety is undefined, it will throw an error. * * @returns The `IDManagedAgent` instance that represents the current execution * context. * * @throws Will throw an error if the `agent` instance property is undefined. */ get agent(): IDManagedAgent { if (this._agent === undefined) { throw new Error('DidManager: Unable to determine agent execution context.'); } return this._agent; } set agent(agent: IDManagedAgent) { this._agent = agent; } public async clear(): Promise<void> { await this._db.clear(); } public async pull(): Promise<void> { const syncPeerState = await this.getSyncPeerState({ syncDirection: 'pull' }); await this.enqueueOperations({ syncDirection: 'pull', syncPeerState }); const pullQueue = this.getPullQueue(); const pullJobs = await pullQueue.iterator().all(); const deleteOperations: DbBatchOperation[] = []; const errored: Set<string> = new Set(); for (let job of pullJobs) { const [key] = job; const [did, dwnUrl, watermark, messageCid] = key.split('~'); // If a particular DWN service endpoint is unreachable, skip subsequent pull operations. if (errored.has(dwnUrl)) { continue; } const messageExists = await this.messageExists(did, messageCid); if (messageExists) { await this.setWatermark(did, dwnUrl, 'pull', watermark); deleteOperations.push({ type: 'del', key: key }); continue; } const messagesGet = await this.agent.dwnManager.createMessage({ author : did, messageType : 'MessagesGet', messageOptions : { messageCids: [messageCid] } }); let reply: MessagesGetReply; try { reply = await this.agent.rpcClient.sendDwnRequest({ dwnUrl, targetDid : did, message : messagesGet }) as MessagesGetReply; } catch(e) { errored.add(dwnUrl); continue; } for (let entry of reply.messages ?? []) { if (entry.error || !entry.message) { await this.setWatermark(did, dwnUrl, 'pull', watermark); await this.addMessage(did, messageCid); deleteOperations.push({ type: 'del', key: key }); continue; } const messageType = this.getDwnMessageType(entry.message); let dataStream; if (messageType === 'RecordsWrite') { const { encodedData } = entry; const message = entry.message as RecordsWriteMessage; if (encodedData) { const dataBytes = Convert.base64Url(encodedData).toUint8Array(); dataStream = DataStream.fromBytes(dataBytes); } else { const recordsRead = await this.agent.dwnManager.createMessage({ author : did, messageType : 'RecordsRead', messageOptions : { filter: { recordId: message.recordId } } }); const recordsReadReply = await this.agent.rpcClient.sendDwnRequest({ dwnUrl, targetDid : did, message : recordsRead.message }); const { record, status: readStatus } = recordsReadReply; if (is2xx(readStatus.code) && record) { /** If the read was successful, convert the data stream from web ReadableStream * to Node.js Readable so that the DWN can process it.*/ dataStream = webReadableToIsomorphicNodeReadable(record.data as any); } else if (readStatus.code >= 400) { const pruneReply = await this.agent.dwnManager.writePrunedRecord({ targetDid: did, message }); if (pruneReply.status.code === 202 || pruneReply.status.code === 409) { await this.setWatermark(did, dwnUrl, 'pull', watermark); await this.addMessage(did, messageCid); deleteOperations.push({ type: 'del', key: key }); continue; } else { throw new Error(`SyncManager: Failed to sync tombstone for message '${messageCid}'`); } } } } const pullReply = await this.agent.dwnManager.processMessage({ targetDid : did, message : entry.message, dataStream }); if (pullReply.status.code === 202 || pullReply.status.code === 409) { await this.setWatermark(did, dwnUrl, 'pull', watermark); await this.addMessage(did, messageCid); deleteOperations.push({ type: 'del', key: key }); } } } await pullQueue.batch(deleteOperations as any); } public async push(): Promise<void> { const syncPeerState = await this.getSyncPeerState({ syncDirection: 'push' }); await this.enqueueOperations({ syncDirection: 'push', syncPeerState }); const pushQueue = this.getPushQueue(); const pushJobs = await pushQueue.iterator().all(); const deleteOperations: DbBatchOperation[] = []; const errored: Set<string> = new Set(); for (let job of pushJobs) { const [key] = job; const [did, dwnUrl, watermark, messageCid] = key.split('~'); // If a particular DWN service endpoint is unreachable, skip subsequent push operations. if (errored.has(dwnUrl)) { continue; } // Attempt to retrieve the message from the local DWN. const dwnMessage = await this.getDwnMessage(did, messageCid); /** If the message does not exist on the local DWN, remove the sync operation from the * push queue, update the push watermark for this DID/DWN endpoint combination, add the * message to the local message store, and continue to the next job. */ if (!dwnMessage) { deleteOperations.push({ type: 'del', key: key }); await this.setWatermark(did, dwnUrl, 'push', watermark); await this.addMessage(did, messageCid); continue; } try { const reply = await this.agent.rpcClient.sendDwnRequest({ dwnUrl, targetDid : did, data : dwnMessage.data, message : dwnMessage.message }); /** Update the watermark and add the messageCid to the Sync Message Store if either: * - 202: message was successfully written to the remote DWN * - 409: message was already present on the remote DWN */ if (reply.status.code === 202 || reply.status.code === 409) { await this.setWatermark(did, dwnUrl, 'push', watermark); await this.addMessage(did, messageCid); deleteOperations.push({ type: 'del', key: key }); } } catch { // Error is intentionally ignored; 'errored' set is updated with 'dwnUrl'. errored.add(dwnUrl); } } await pushQueue.batch(deleteOperations as any); } public async registerIdentity(options: { did: string }): Promise<void> { const { did } = options; const registeredIdentities = this._db.sublevel('registeredIdentities'); await registeredIdentities.put(did, ''); } public startSync(options: { interval: number }): Promise<void> { const { interval = 60_000 } = options; return new Promise((resolve, reject) => { if (this._syncIntervalId) { clearInterval(this._syncIntervalId); } this._syncIntervalId = setInterval(async () => { try { await this.push(); await this.pull(); } catch (error) { this.stopSync(); reject(error); } }, interval); }); } public stopSync(): void { if (this._syncIntervalId) { clearInterval(this._syncIntervalId); this._syncIntervalId = undefined; } } private async enqueueOperations(options: { syncDirection: SyncDirection, syncPeerState: SyncState[] }) { const { syncDirection, syncPeerState } = options; for (let syncState of syncPeerState) { // Get the event log from the remote DWN if pull sync, or local DWN if push sync. const eventLog = await this.getDwnEventLog({ did : syncState.did, dwnUrl : syncState.dwnUrl, syncDirection, watermark : syncState.watermark }); const syncOperations: DbBatchOperation[] = []; for (let event of eventLog) { /** Use "did~dwnUrl~watermark~messageCid" as the key in the sync queue. * Note: It is critical that `watermark` precedes `messageCid` to * ensure that when the sync jobs are pulled off the queue, they * are lexographically sorted oldest to newest. */ const operationKey = [ syncState.did, syncState.dwnUrl, event.watermark, event.messageCid ].join('~'); const operation: DbBatchOperation = { type: 'put', key: operationKey, value: '' }; syncOperations.push(operation); } if (syncOperations.length > 0) { const syncQueue = (syncDirection === 'pull') ? this.getPullQueue() : this.getPushQueue(); await syncQueue.batch(syncOperations as any); } } } private async getDwnEventLog(options: { did: string, dwnUrl: string, syncDirection: SyncDirection, watermark?: string }) { const { did, dwnUrl, syncDirection, watermark } = options; let eventsReply = {} as EventsGetReply; if (syncDirection === 'pull') { // When sync is a pull, get the event log from the remote DWN. const eventsGetMessage = await this.agent.dwnManager.createMessage({ author : did, messageType : 'EventsGet', messageOptions : { watermark } }); try { eventsReply = await this.agent.rpcClient.sendDwnRequest({ dwnUrl : dwnUrl, targetDid : did, message : eventsGetMessage }); } catch { // If a particular DWN service endpoint is unreachable, silently ignore. } } else if (syncDirection === 'push') { // When sync is a push, get the event log from the local DWN. ({ reply: eventsReply } = await this.agent.dwnManager.processRequest({ author : did, target : did, messageType : 'EventsGet', messageOptions : { watermark } })); } const eventLog = eventsReply.events ?? []; return eventLog; } private async getDwnMessage( author: string, messageCid: string ): Promise<DwnMessage | undefined> { let messagesGetResponse = await this.agent.dwnManager.processRequest({ author : author, target : author, messageType : 'MessagesGet', messageOptions : { messageCids: [messageCid] } }); const reply: MessagesGetReply = messagesGetResponse.reply; /** Absence of a messageEntry or message within messageEntry can happen because updating a * Record creates another RecordsWrite with the same recordId. Only the first and * most recent RecordsWrite messages are kept for a given recordId. Any RecordsWrite messages * that aren't the first or most recent are discarded by the DWN. */ if (!(reply.messages && reply.messages.length === 1)) { return undefined; } const [ messageEntry ] = reply.messages; let { message } = messageEntry; if (!message) { return undefined; } let dwnMessage: DwnMessage = { message }; const messageType = `${message.descriptor.interface}${message.descriptor.method}`; // if the message is a RecordsWrite, either data will be present, OR we have to get it using a RecordsRead if (messageType === 'RecordsWrite') { const { encodedData } = messageEntry; const writeMessage = message as RecordsWriteMessage; if (encodedData) { const dataBytes = Convert.base64Url(encodedData).toUint8Array(); dwnMessage.data = new Blob([dataBytes]); } else { let readResponse = await this.agent.dwnManager.processRequest({ author : author, target : author, messageType : 'RecordsRead', messageOptions : { filter: { recordId: writeMessage.recordId } } }); const reply = readResponse.reply; if (is2xx(reply.status.code) && reply.record) { // If status code is 200-299, return the data. const dataBytes = await DataStream.toBytes(reply.record.data); dwnMessage.data = new Blob([dataBytes]); } else if (is4xx(reply.status.code)) { /** If status code is 400-499, typically 404 indicating the data no longer exists, it is * likely that a `RecordsDelete` took place. `RecordsDelete` keeps a `RecordsWrite` and * deletes the associated data, effectively acting as a "tombstone." Sync still needs to * _push_ this tombstone so that the `RecordsDelete` can be processed successfully. */ } else { // If status code is anything else (likely 5xx), throw an error. const { status } = reply; throw new Error(`SyncManager: Failed to read data associated with record ${writeMessage.recordId}. (${status.code}) ${status.detail}}`); } } } return dwnMessage; } private async getSyncPeerState(options: { syncDirection: SyncDirection }): Promise<SyncState[]> { const { syncDirection } = options; // Get a list of the DIDs of all registered identities. const registeredIdentities = await this._db.sublevel('registeredIdentities').keys().all(); // Array to accumulate the list of sync peers for each DID. const syncPeerState: SyncState[] = []; for (let did of registeredIdentities) { // Resolve the DID to its DID document. const { didDocument, didResolutionMetadata } = await this.agent.didResolver.resolve(did); // If DID resolution fails, throw an error. if (!didDocument) { const errorCode = `${didResolutionMetadata?.error}: ` ?? ''; const defaultMessage = `Unable to resolve DID: ${did}`; const errorMessage = didResolutionMetadata?.errorMessage ?? defaultMessage; throw new Error(`SyncManager: ${errorCode}${errorMessage}`); } // Attempt to get the `#dwn` service entry from the DID document. const [ service ] = didUtils.getServices({ didDocument, id: '#dwn' }); /** Silently ignore and do not try to perform Sync for any DID that does not have a DWN * service endpoint published in its DID document. **/ if (!service) { continue; } if (!didUtils.isDwnServiceEndpoint(service.serviceEndpoint)) { throw new Error(`SyncManager: Malformed '#dwn' service endpoint. Expected array of node addresses.`); } /** Get the watermark (or undefined) for each (DID, DWN service endpoint, sync direction) * combination and add it to the sync peer state array. */ for (let dwnUrl of service.serviceEndpoint.nodes) { const watermark = await this.getWatermark(did, dwnUrl, syncDirection); syncPeerState.push({ did, dwnUrl, watermark }); } } return syncPeerState; } private async getWatermark(did: string, dwnUrl: string, direction: SyncDirection) { const wmKey = `${did}~${dwnUrl}~${direction}`; const watermarkStore = this.getWatermarkStore(); try { return await watermarkStore.get(wmKey); } catch(error: any) { // Don't throw when a key wasn't found. if (error.notFound) { return undefined; } } } private async setWatermark(did: string, dwnUrl: string, direction: SyncDirection, watermark: string) { const wmKey = `${did}~${dwnUrl}~${direction}`; const watermarkStore = this.getWatermarkStore(); await watermarkStore.put(wmKey, watermark); } /** * The message store is used to prevent "echoes" that occur during a sync pull operation. * After a message is confirmed to already be synchronized on the local DWN, its CID is added * to the message store to ensure that any subsequent pull attempts are skipped. */ private async messageExists(did: string, messageCid: string) { const messageStore = this.getMessageStore(did); // If the `messageCid` exists in this DID's store, return true. Otherwise, return false. try { await messageStore.get(messageCid); return true; } catch (error: any) { if (error.notFound) { return false; } throw error; } } private async addMessage(did: string, messageCid: string) { const messageStore = this.getMessageStore(did); return await messageStore.put(messageCid, ''); } private getMessageStore(did: string) { return this._db.sublevel('history').sublevel(did).sublevel('messages'); } private getWatermarkStore() { return this._db.sublevel('watermarks'); } private getPushQueue() { return this._db.sublevel('pushQueue'); } private getPullQueue() { return this._db.sublevel('pullQueue'); } private getDwnMessageType(message: GenericMessage) { return `${message.descriptor.interface}${message.descriptor.method}`; } }