UNPKG

@web5/agent

Version:
400 lines (324 loc) 15 kB
import type { Jwk } from '@web5/crypto'; import ms from 'ms'; import { Convert, NodeStream, TtlCache } from '@web5/common'; import type { Web5PlatformAgent } from './types/agent.js'; import { TENANT_SEPARATOR } from './utils-internal.js'; import { getDataStoreTenant } from './utils-internal.js'; import { DwnInterface, DwnMessageParams } from './types/dwn.js'; import { ProtocolDefinition, RecordsReadReplyEntry } from '@tbd54566975/dwn-sdk-js'; export type DataStoreTenantParams = { agent: Web5PlatformAgent; tenant?: string; } export type DataStoreListParams = DataStoreTenantParams; export type DataStoreGetParams = DataStoreTenantParams & { id: string; useCache?: boolean; } export type DataStoreSetParams<TStoreObject> = DataStoreTenantParams & { id: string; data: TStoreObject; preventDuplicates?: boolean; updateExisting?: boolean; useCache?: boolean; } export type DataStoreDeleteParams = DataStoreTenantParams & { id: string; } export interface AgentDataStore<TStoreObject> { delete(params: DataStoreDeleteParams): Promise<boolean>; get(params: DataStoreGetParams): Promise<TStoreObject | undefined>; list(params: DataStoreTenantParams): Promise<TStoreObject[]>; set(params: DataStoreSetParams<TStoreObject>): Promise<void>; } export class DwnDataStore<TStoreObject extends Record<string, any> = Jwk> implements AgentDataStore<TStoreObject> { protected name = 'DwnDataStore'; /** * Cache of Store Objects referenced by DWN record ID to Store Objects. * * Up to 100 entries are retained for 15 minutes. */ protected _cache = new TtlCache<string, TStoreObject>({ ttl: ms('15 minutes'), max: 100 }); /** * Index for mappings from Store Identifier to DWN record ID. * Since these values don't change, we can use a long TTL. * * Up to 1,000 entries are retained for 21 days. * NOTE: The maximum number for the ttl is 2^31 - 1 milliseconds (24.8 days), setting to 21 days to be safe. */ protected _index = new TtlCache<string, string>({ ttl: ms('21 days'), max: 1000 }); /** * Cache of tenant DIDs that have been initialized with the protocol. * This is used to avoid redundant protocol initialization requests. * * Since these are default protocols and unlikely to change, we can use a long TTL. */ protected _protocolInitializedCache: TtlCache<string, boolean> = new TtlCache({ ttl: ms('21 days'), max: 1000 }); /** * The protocol assigned to this storage instance. */ protected _recordProtocolDefinition!: ProtocolDefinition; /** * Properties to use when writing and querying records with the DWN store. */ protected _recordProperties = { dataFormat: 'application/json', }; public async delete({ id, agent, tenant }: DataStoreDeleteParams): Promise<boolean> { // Determine the tenant identifier (DID) for the delete operation. const tenantDid = await getDataStoreTenant({ agent, tenant, didUri: id }); // Look up the DWN record ID of the object in the store with the given `id`. let matchingRecordId = await this.lookupRecordId({ id, tenantDid, agent }); // Return false if the given ID was not found in the store. if (!matchingRecordId) return false; // If a record for the given ID was found, attempt to delete it. const { reply: { status } } = await agent.dwn.processRequest({ author : tenantDid, target : tenantDid, messageType : DwnInterface.RecordsDelete, messageParams : { recordId: matchingRecordId } }); // If the record was successfully deleted, update the index/cache and return true; if (status.code === 202) { this._index.delete(`${tenantDid}${TENANT_SEPARATOR}${id}`); this._cache.delete(matchingRecordId); return true; } // If the Delete operation failed, throw an error. throw new Error(`${this.name}: Failed to delete '${id}' from store: (${status.code}) ${status.detail}`); } public async get({ id, agent, tenant, useCache = false }: DataStoreGetParams ): Promise<TStoreObject | undefined> { // Determine the tenant identifier (DID) for the list operation. const tenantDid = await getDataStoreTenant({ agent, tenant, didUri: id }); // Look up the DWN record ID of the object in the store with the given `id`. let matchingRecordId = await this.lookupRecordId({ id, tenantDid, agent }); // Return undefined if no matches were found. if (!matchingRecordId) return undefined; // Retrieve and return the stored object. return await this.getRecord({ recordId: matchingRecordId, tenantDid, agent, useCache }); } public async list({ agent, tenant}: DataStoreListParams): Promise<TStoreObject[]> { // Determine the tenant identifier (DID) for the list operation. const tenantDid = await getDataStoreTenant({ tenant, agent }); // Query the DWN for all stored record objects. const storedRecords = await this.getAllRecords({ agent, tenantDid }); return storedRecords; } public async set({ id, data, tenant, agent, preventDuplicates = true, updateExisting = false, useCache = false }: DataStoreSetParams<TStoreObject> ): Promise<void> { // Determine the tenant identifier (DID) for the set operation. const tenantDid = await getDataStoreTenant({ agent, tenant, didUri: id }); // initialize the storage protocol if not already done await this.initialize({ tenant: tenantDid, agent }); const messageParams: DwnMessageParams[DwnInterface.RecordsWrite] = { ...this._recordProperties }; if (updateExisting) { // Look up the DWN record ID of the object in the store with the given `id`. const matchingRecordEntry = await this.getExistingRecordEntry({ id, tenantDid, agent }); if (!matchingRecordEntry) { throw new Error(`${this.name}: Update failed due to missing entry for: ${id}`); } // set the recordId in the messageParams to update the existing record // set the dateCreated to the existing dateCreated as this is an immutable property messageParams.recordId = matchingRecordEntry.recordsWrite!.recordId; messageParams.dateCreated = matchingRecordEntry.recordsWrite!.descriptor.dateCreated; } else if (preventDuplicates) { // Look up the DWN record ID of the object in the store with the given `id`. const matchingRecordId = await this.lookupRecordId({ id, tenantDid, agent }); if (matchingRecordId) { throw new Error(`${this.name}: Import failed due to duplicate entry for: ${id}`); } } // Convert the store object to a byte array, which will be the data payload of the DWN record. const dataBytes = Convert.object(data).toUint8Array(); // Store the record in the DWN. const { message, reply: { status } } = await agent.dwn.processRequest({ author : tenantDid, target : tenantDid, messageType : DwnInterface.RecordsWrite, messageParams : { ...this._recordProperties, ...messageParams }, dataStream : new Blob([dataBytes], { type: 'application/json' }) }); // If the write fails, throw an error. if (!(message && status.code === 202)) { throw new Error(`${this.name}: Failed to write data to store for ${id}: ${status.detail}`); } // Add the ID of the newly created record to the index. this._index.set(`${tenantDid}${TENANT_SEPARATOR}${id}`, message.recordId); // If caching is enabled, add the store object to the cache. if (useCache) { this._cache.set(message.recordId, data); } } /** * Initialize the relevant protocol for the given tenant. * This confirms that the storage protocol is configured, otherwise it will be installed. */ public async initialize({ tenant, agent }: DataStoreTenantParams) { const tenantDid = await getDataStoreTenant({ agent, tenant }); if (this._protocolInitializedCache.has(tenantDid)) { return; } const { reply: { status, entries }} = await agent.dwn.processRequest({ author : tenantDid, target : tenantDid, messageType : DwnInterface.ProtocolsQuery, messageParams : { filter: { protocol: this._recordProtocolDefinition.protocol } }, }); if (status.code !== 200) { throw new Error(`Failed to query for protocols: ${status.code} - ${status.detail}`); } if (entries?.length === 0) { // protocol is not installed, install it await this.installProtocol(tenantDid, agent); } this._protocolInitializedCache.set(tenantDid, true); } protected async getAllRecords(_params: { agent: Web5PlatformAgent; tenantDid: string; }): Promise<TStoreObject[]> { throw new Error('Not implemented: Classes extending DwnDataStore must implement getAllRecords()'); } private async getRecord({ recordId, tenantDid, agent, useCache }: { recordId: string; tenantDid: string; agent: Web5PlatformAgent; useCache: boolean; }): Promise<TStoreObject | undefined> { // If caching is enabled, check the cache for the record ID. if (useCache) { const record = this._cache.get(recordId); // If the record ID was present in the cache, return the associated store object. if (record) return record; // Otherwise, continue to read from the store. } // Read the record from the store. const { reply: readReply } = await agent.dwn.processRequest({ author : tenantDid, target : tenantDid, messageType : DwnInterface.RecordsRead, messageParams : { filter: { recordId } } }); if (!readReply.entry?.data) { throw new Error(`${this.name}: Failed to read data from DWN for: ${recordId}`); } // If the record was found, convert back to store object format. const storeObject = await NodeStream.consumeToJson({ readable: readReply.entry.data }) as TStoreObject; // If caching is enabled, add the store object to the cache. if (useCache) { this._cache.set(recordId, storeObject); } return storeObject; } /** * Install the protocol for the given tenant using a `ProtocolsConfigure` message. */ private async installProtocol(tenant: string, agent: Web5PlatformAgent) { const { reply : { status } } = await agent.dwn.processRequest({ author : tenant, target : tenant, messageType : DwnInterface.ProtocolsConfigure, messageParams : { definition: this._recordProtocolDefinition }, }); if (status.code !== 202) { throw new Error(`Failed to install protocol: ${status.code} - ${status.detail}`); } } private async lookupRecordId({ id, tenantDid, agent }: { id: string; tenantDid: string; agent: Web5PlatformAgent; }): Promise<string | undefined> { // Check the index for a matching ID and extend the index TTL. let recordId = this._index.get(`${tenantDid}${TENANT_SEPARATOR}${id}`, { updateAgeOnGet: true }); // If no matching record ID was found in the index... if (!recordId) { // Query the DWN for all stored objects, which rebuilds the index. await this.getAllRecords({ agent, tenantDid }); // Check the index again for a matching ID. recordId = this._index.get(`${tenantDid}${TENANT_SEPARATOR}${id}`); } return recordId; } private async getExistingRecordEntry({ id, tenantDid, agent }: { id: string; tenantDid: string; agent: Web5PlatformAgent; }): Promise<RecordsReadReplyEntry | undefined> { // Look up the DWN record ID of the object in the store with the given `id`. const recordId = await this.lookupRecordId({ id, tenantDid, agent }); if (recordId) { // Read the record from the store. const { reply: readReply } = await agent.dwn.processRequest({ author : tenantDid, target : tenantDid, messageType : DwnInterface.RecordsRead, messageParams : { filter: { recordId } } }); return readReply.entry; } } } export class InMemoryDataStore<TStoreObject extends Record<string, any> = Jwk> implements AgentDataStore<TStoreObject> { protected name = 'InMemoryDataStore'; /** * A private field that contains the Map used as the in-memory data store. */ private store: Map<string, TStoreObject> = new Map(); public async delete({ id, agent, tenant }: DataStoreDeleteParams): Promise<boolean> { // Determine the tenant identifier (DID) for the delete operation. const tenantDid = await getDataStoreTenant({ agent, tenant, didUri: id }); if (this.store.has(`${tenantDid}${TENANT_SEPARATOR}${id}`)) { // Record with given identifier exists so proceed with delete. this.store.delete(`${tenantDid}${TENANT_SEPARATOR}${id}`); return true; } // Record with given identifier not present so delete operation not possible. return false; } public async get({ id, agent, tenant }: DataStoreGetParams): Promise<TStoreObject | undefined> { // Determine the tenant identifier (DID) for the get operation. const tenantDid = await getDataStoreTenant({ agent, tenant, didUri: id }); return this.store.get(`${tenantDid}${TENANT_SEPARATOR}${id}`); } public async list({ agent, tenant}: DataStoreListParams): Promise<TStoreObject[]> { // Determine the tenant identifier (DID) for the list operation. const tenantDid = await getDataStoreTenant({ tenant, agent }); const result: TStoreObject[] = []; for (const [key, storedRecord] of this.store.entries()) { if (key.startsWith(`${tenantDid}${TENANT_SEPARATOR}`)) { result.push(storedRecord); } } return result; } public async set({ id, data, tenant, agent, preventDuplicates, updateExisting }: DataStoreSetParams<TStoreObject>): Promise<void> { // Determine the tenant identifier (DID) for the set operation. const tenantDid = await getDataStoreTenant({ agent, tenant, didUri: id }); // If enabled, check if a record with the given `id` is already present in the store. if (updateExisting) { // Look up the DWN record ID of the object in the store with the given `id`. if (!this.store.has(`${tenantDid}${TENANT_SEPARATOR}${id}`)) { throw new Error(`${this.name}: Update failed due to missing entry for: ${id}`); } // set the recordId in the messageParams to update the existing record } else if (preventDuplicates) { const duplicateFound = this.store.has(`${tenantDid}${TENANT_SEPARATOR}${id}`); if (duplicateFound) { throw new Error(`${this.name}: Import failed due to duplicate entry for: ${id}`); } } // Make a deep copy so that the object stored does not share the same references as the input. const clonedData = structuredClone(data); this.store.set(`${tenantDid}${TENANT_SEPARATOR}${id}`, clonedData); } }