diff --git a/.changeset/eighty-spoons-cross.md b/.changeset/eighty-spoons-cross.md new file mode 100644 index 000000000..e92419c4d --- /dev/null +++ b/.changeset/eighty-spoons-cross.md @@ -0,0 +1,5 @@ +--- +"@web5/api": minor +--- + +Implement a `delete` method and state on the `@web5/api` `Record` class diff --git a/packages/api/src/record.ts b/packages/api/src/record.ts index 582659ff5..207434755 100644 --- a/packages/api/src/record.ts +++ b/packages/api/src/record.ts @@ -7,39 +7,61 @@ import type { Readable } from '@web5/common'; import { Web5Agent, + DwnInterface, DwnMessage, DwnMessageParams, DwnResponseStatus, ProcessDwnRequest, DwnMessageDescriptor, getPaginationCursor, + getRecordAuthor, DwnDateSort, - DwnPaginationCursor + DwnPaginationCursor, + isDwnMessage, + SendDwnRequest } from '@web5/agent'; -import { DwnInterface } from '@web5/agent'; import { Convert, isEmptyObject, NodeStream, removeUndefinedProperties, Stream } from '@web5/common'; import { dataToBlob, SendCache } from './utils.js'; +/** + * Represents Immutable Record properties that cannot be changed after the record is created. + * + * @beta + * */ +export type ImmutableRecordProperties = + Pick; + +/** + * Represents Optional Record properties that depend on the Record's current state. + * + * @beta +*/ +export type OptionalRecordProperties = + Pick & + Pick; + /** * Represents the structured data model of a record, encapsulating the essential fields that define * the record's metadata and payload within a Decentralized Web Node (DWN). * * @beta */ -export type RecordModel = DwnMessageDescriptor[DwnInterface.RecordsWrite] - & Omit - & { - /** The DID that signed the record. */ - author: string; +export type RecordModel = ImmutableRecordProperties & OptionalRecordProperties & { + + /** The logical author of the record. */ + author: string; - /** The protocol role under which this record is written. */ - protocolRole?: RecordOptions['protocolRole']; + /** The unique identifier of the record. */ + recordId?: string; - /** The unique identifier of the record. */ - recordId?: string; - } + /** The timestamp indicating when the record was last modified. */ + messageTimestamp?: string; + + /** The protocol role under which this record is written. */ + protocolRole?: RecordOptions['protocolRole']; +} /** * Options for configuring a {@link Record} instance, extending the base `RecordsWriteMessage` with @@ -81,7 +103,7 @@ export type RecordOptions = DwnMessage[DwnInterface.RecordsWrite] & { /** * Parameters for updating a DWN record. * - * This type specifies the set of properties that can be updated on an existing record. It is used + * This type specifies the set of properties that can be updated on an existing record. It is used * to convey the new state or changes to be applied to the record. * * @beta @@ -120,18 +142,27 @@ export type RecordUpdateParams = { } /** - * Record wrapper class with convenience methods to send and update, - * aside from manipulating and reading the record data. + * Parameters for deleting a DWN record. * - * Note: The `messageTimestamp` of the most recent RecordsWrite message is - * logically equivalent to the date/time at which a Record was most - * recently modified. Since this Record class implementation is - * intended to simplify the developer experience of working with - * logical records (and not individual DWN messages) the - * `messageTimestamp` is mapped to `dateModified`. + * This type specifies the set of properties that are used when deleting an existing record. It is used + * to convey the new state or changes to be applied to the record. * * @beta */ +export type RecordDeleteParams = { + /** Whether or not to store the message. */ + store?: boolean; + + /** Whether or not to sign the delete as an owner in order to import it. */ + signAsOwner?: boolean; + + /** Whether or not to prune any children this record may have. */ + prune?: DwnMessageDescriptor[DwnInterface.RecordsDelete]['prune']; + + /** The timestamp indicating when the record was deleted. */ + dateModified?: DwnMessageDescriptor[DwnInterface.RecordsDelete]['messageTimestamp']; +}; + /** * The `Record` class encapsulates a single record's data and metadata, providing a more * developer-friendly interface for working with Decentralized Web Node (DWN) records. @@ -139,6 +170,13 @@ export type RecordUpdateParams = { * Methods are provided to read, update, and manage the record's lifecycle, including writing to * remote DWNs. * + * Note: The `messageTimestamp` of the most recent RecordsWrite message is + * logically equivalent to the date/time at which a Record was most + * recently modified. Since this Record class implementation is + * intended to simplify the developer experience of working with + * logical records (and not individual DWN messages) the + * `messageTimestamp` is mapped to `dateModified`. + * * @beta */ export class Record implements RecordModel { @@ -168,11 +206,11 @@ export class Record implements RecordModel { /** Attestation JWS signature. */ private _attestation?: DwnMessage[DwnInterface.RecordsWrite]['attestation']; /** Authorization signature(s). */ - private _authorization?: DwnMessage[DwnInterface.RecordsWrite]['authorization']; + private _authorization?: DwnMessage[DwnInterface.RecordsWrite | DwnInterface.RecordsDelete]['authorization']; /** Context ID associated with the record. */ private _contextId?: string; /** Descriptor detailing the record's schema, format, and other metadata. */ - private _descriptor: DwnMessageDescriptor[DwnInterface.RecordsWrite]; + private _descriptor: DwnMessageDescriptor[DwnInterface.RecordsWrite] | DwnMessageDescriptor[DwnInterface.RecordsDelete]; /** Encryption details for the record, if the data is encrypted. */ private _encryption?: DwnMessage[DwnInterface.RecordsWrite]['encryption']; /** Initial state of the record before any updates. */ @@ -184,96 +222,114 @@ export class Record implements RecordModel { /** Unique identifier of the record. */ private _recordId: string; /** Role under which the record is written. */ - private _protocolRole: RecordOptions['protocolRole']; + private _protocolRole?: RecordOptions['protocolRole']; - // Getters for immutable DWN Record properties. + /** The `RecordsWriteMessage` descriptor unless the record is in a deleted state */ + private get _recordsWriteDescriptor() { + if (isDwnMessage(DwnInterface.RecordsWrite, this.rawMessage)) { + return this._descriptor as DwnMessageDescriptor[DwnInterface.RecordsWrite]; + } - /** Record's signatures attestation */ - get attestation(): DwnMessage[DwnInterface.RecordsWrite]['attestation'] { return this._attestation; } + return undefined; // returns undefined if the descriptor does not represent a RecordsWrite message. + } - /** Record's signatures attestation */ - get authorization(): DwnMessage[DwnInterface.RecordsWrite]['authorization'] { return this._authorization; } + /** The `RecordsWrite` descriptor from the current record or the initial write if the record is in a delete state. */ + private get _immutableProperties(): ImmutableRecordProperties { + return this._recordsWriteDescriptor || this._initialWrite.descriptor; + } - /** DID that signed the record. */ - get author(): string { return this._author; } + // Getters for immutable Record properties. + /** Record's ID */ + get id() { return this._recordId; } /** Record's context ID */ get contextId() { return this._contextId; } - /** Record's data format */ - get dataFormat() { return this._descriptor.dataFormat; } - /** Record's creation date */ - get dateCreated() { return this._descriptor.dateCreated; } - - /** Record's encryption */ - get encryption(): DwnMessage[DwnInterface.RecordsWrite]['encryption'] { return this._encryption; } - - /** Record's initial write if the record has been updated */ - get initialWrite(): RecordOptions['initialWrite'] { return this._initialWrite; } - - /** Record's ID */ - get id() { return this._recordId; } - - /** Interface is always `Records` */ - get interface() { return this._descriptor.interface; } - - /** Method is always `Write` */ - get method() { return this._descriptor.method; } + get dateCreated() { return this._immutableProperties.dateCreated; } /** Record's parent ID */ - get parentId() { return this._descriptor.parentId; } + get parentId() { return this._immutableProperties.parentId; } /** Record's protocol */ - get protocol() { return this._descriptor.protocol; } + get protocol() { return this._immutableProperties.protocol; } /** Record's protocol path */ - get protocolPath() { return this._descriptor.protocolPath; } - - /** Role under which the author is writing the record */ - get protocolRole() { return this._protocolRole; } + get protocolPath() { return this._immutableProperties.protocolPath; } /** Record's recipient */ - get recipient() { return this._descriptor.recipient; } + get recipient() { return this._immutableProperties.recipient; } /** Record's schema */ - get schema() { return this._descriptor.schema; } + get schema() { return this._immutableProperties.schema; } - // Getters for mutable DWN Record properties. + + // Getters for mutable DWN RecordsWrite properties that may be undefined in a deleted state. + /** Record's data format */ + get dataFormat() { return this._recordsWriteDescriptor?.dataFormat; } /** Record's CID */ - get dataCid() { return this._descriptor.dataCid; } + get dataCid() { return this._recordsWriteDescriptor?.dataCid; } /** Record's data size */ - get dataSize() { return this._descriptor.dataSize; } + get dataSize() { return this._recordsWriteDescriptor?.dataSize; } + + /** Record's published date */ + get datePublished() { return this._recordsWriteDescriptor?.datePublished; } + + /** Record's published status (true/false) */ + get published() { return this._recordsWriteDescriptor?.published; } + + /** Tags of the record */ + get tags() { return this._recordsWriteDescriptor?.tags; } + + + // Getters for for properties that depend on the current state of the Record. + /** DID that is the logical author of the Record. */ + get author(): string { return this._author; } /** Record's modified date */ get dateModified() { return this._descriptor.messageTimestamp; } - /** Record's published date */ - get datePublished() { return this._descriptor.datePublished; } + /** Record's encryption */ + get encryption(): DwnMessage[DwnInterface.RecordsWrite]['encryption'] { return this._encryption; } - /** Record's published status */ - get messageTimestamp() { return this._descriptor.messageTimestamp; } + /** Record's signatures attestation */ + get authorization(): DwnMessage[DwnInterface.RecordsWrite | DwnInterface.RecordsDelete]['authorization'] { return this._authorization; } - /** Record's published status (true/false) */ - get published() { return this._descriptor.published; } + /** Record's signatures attestation */ + get attestation(): DwnMessage[DwnInterface.RecordsWrite]['attestation'] | undefined { return this._attestation; } - /** Tags of the record */ - get tags() { return this._descriptor.tags; } + /** Role under which the author is writing the record */ + get protocolRole() { return this._protocolRole; } + + /** Record's deleted state (true/false) */ + get deleted() { return isDwnMessage(DwnInterface.RecordsDelete, this.rawMessage); } + + /** Record's initial write if the record has been updated */ + get initialWrite(): RecordOptions['initialWrite'] { return this._initialWrite; } /** * Returns a copy of the raw `RecordsWriteMessage` that was used to create the current `Record` instance. */ - private get rawMessage(): DwnMessage[DwnInterface.RecordsWrite] { - const message = JSON.parse(JSON.stringify({ - contextId : this._contextId, - recordId : this._recordId, - descriptor : this._descriptor, - attestation : this._attestation, - authorization : this._authorization, - encryption : this._encryption, - })); + private get rawMessage(): DwnMessage[DwnInterface.RecordsWrite] | DwnMessage[DwnInterface.RecordsDelete] { + const messageType = this._descriptor.interface + this._descriptor.method; + let message: DwnMessage[DwnInterface.RecordsWrite] | DwnMessage[DwnInterface.RecordsDelete]; + if (messageType === DwnInterface.RecordsWrite) { + message = JSON.parse(JSON.stringify({ + contextId : this._contextId, + recordId : this._recordId, + descriptor : this._descriptor, + attestation : this._attestation, + authorization : this._authorization, + encryption : this._encryption, + })); + } else { + message = JSON.parse(JSON.stringify({ + descriptor : this._descriptor, + authorization : this._authorization, + })); + } removeUndefinedProperties(message); return message; @@ -516,15 +572,26 @@ export class Record implements RecordModel { Record._sendCache.set(this._recordId, target); } - // Send the current/latest state to the target. - const { reply } = await this._agent.sendDwnRequest({ - messageType : DwnInterface.RecordsWrite, - author : this._connectedDid, - dataStream : await this.data.blob(), - target : target, - rawMessage : { ...this.rawMessage } - }); + let sendRequestOptions: SendDwnRequest; + if (this.deleted) { + sendRequestOptions = { + messageType : DwnInterface.RecordsDelete, + author : this._connectedDid, + target : target, + rawMessage : { ...this.rawMessage } + }; + } else { + sendRequestOptions = { + messageType : DwnInterface.RecordsWrite, + author : this._connectedDid, + target : target, + dataStream : await this.data.blob(), + rawMessage : { ...this.rawMessage } + }; + } + // Send the current/latest state to the target. + const { reply } = await this._agent.sendDwnRequest(sendRequestOptions); return reply; } @@ -545,8 +612,6 @@ export class Record implements RecordModel { messageTimestamp : this.dateModified, datePublished : this.datePublished, encryption : this.encryption, - interface : this.interface, - method : this.method, parentId : this.parentId, protocol : this.protocol, protocolPath : this.protocolPath, @@ -569,9 +634,15 @@ export class Record implements RecordModel { str += this.contextId ? ` Context ID: ${this.contextId}\n` : ''; str += this.protocol ? ` Protocol: ${this.protocol}\n` : ''; str += this.schema ? ` Schema: ${this.schema}\n` : ''; - str += ` Data CID: ${this.dataCid}\n`; - str += ` Data Format: ${this.dataFormat}\n`; - str += ` Data Size: ${this.dataSize}\n`; + + // Only display data properties if the record has not been deleted. + if (!this.deleted) { + str += ` Data CID: ${this.dataCid}\n`; + str += ` Data Format: ${this.dataFormat}\n`; + str += ` Data Size: ${this.dataSize}\n`; + } + + str += ` Deleted: ${this.deleted}\n`; str += ` Created: ${this.dateCreated}\n`; str += ` Modified: ${this.dateModified}\n`; str += `}`; @@ -584,8 +655,8 @@ export class Record implements RecordModel { * @param sort the sort order to use for the pagination cursor. * @returns A promise that resolves to a pagination cursor for the current record. */ - async paginationCursor(sort: DwnDateSort): Promise { - return getPaginationCursor(this.rawMessage, sort); + async paginationCursor(sort: DwnDateSort): Promise { + return isDwnMessage(DwnInterface.RecordsWrite, this.rawMessage) ? getPaginationCursor(this.rawMessage, sort) : undefined; } /** @@ -598,8 +669,12 @@ export class Record implements RecordModel { */ async update({ dateModified, data, ...params }: RecordUpdateParams): Promise { + if (!isDwnMessage(DwnInterface.RecordsWrite, this.rawMessage) && !this._initialWrite) { + throw new Error('If initial write is not set, the current rawRecord must be a RecordsWrite message.'); + } + // if there is a parentId, we remove it from the descriptor and set a parentContextId - const { parentId, ...descriptor } = this._descriptor; + const { parentId, ...descriptor } = this._recordsWriteDescriptor; const parentContextId = parentId ? this._contextId.split('/').slice(0, -1).join('/') : undefined; // Begin assembling the update message. @@ -653,7 +728,9 @@ export class Record implements RecordModel { if (200 <= status.code && status.code <= 299) { // copy the original raw message to the initial write before we update the values. if (!this._initialWrite) { - this._initialWrite = { ...this.rawMessage }; + // If there is no initial write, we need to create one from the current record state. + // We checked in the beginning of the function that the rawMessage is a RecordsWrite message. + this._initialWrite = { ...this.rawMessage as DwnMessage[DwnInterface.RecordsWrite] }; } // Only update the local Record instance mutable properties if the record was successfully (over)written. @@ -672,6 +749,69 @@ export class Record implements RecordModel { return { status }; } + /** + * Delete the current record on the DWN. + * @param params - Parameters to delete the record. + * @returns the status of the delete request + */ + async delete(deleteParams?: RecordDeleteParams): Promise { + const { store, signAsOwner, dateModified, ...params } = deleteParams || {}; + + // prepare delete options + let deleteOptions: ProcessDwnRequest = { + messageType : DwnInterface.RecordsDelete, + author : this._connectedDid, + target : this._connectedDid, + store, + signAsOwner + }; + + if (this.deleted) { + if (!this._initialWrite) { + // if the rawMessage is a `RecordsDelete` the initial message must be set. + // this should never happen, but we check as a form of defensive programming. + throw new Error('If initial write is not set, the current rawRecord must be a RecordsWrite message.'); + } + + // if we have a delete message we can just use it + deleteOptions.rawMessage = this.rawMessage as DwnMessage[DwnInterface.RecordsDelete]; + } else { + // otherwise we construct a delete message given the `RecordDeleteParams` + deleteOptions.messageParams = { + ...params, + recordId : this._recordId, + messageTimestamp : dateModified, + }; + } + + const agentResponse = await this._agent.processDwnRequest(deleteOptions); + const { message, reply: { status } } = agentResponse; + + if (status.code !== 202) { + // If the delete was not successful, return the status. + return { status }; + } + + if (!this._initialWrite) { + // If there is no initial write, we need to create one from the current record state. + // We checked in the beginning of the function that the initialWrite is not set if the rawMessage is a RecordsDelete message. + // So we can safely assume that the rawMessage is a RecordsWrite message. + this._initialWrite = { ...this.rawMessage as DwnMessage[DwnInterface.RecordsWrite] }; + } + + // If the delete was successful, update the Record author to the author of the delete message. + this._author = getRecordAuthor(message); + this._descriptor = message.descriptor; + this._authorization = message.authorization; + + // clear out properties that are not relevant for a deleted record + this._encodedData = undefined; + this._encryption = undefined; + this._attestation = undefined; + + return { status }; + } + /** * Handles the various conditions around there being an initial write, whether to store initial/current state, * and whether to add an owner signature to the initial write to enable storage when protocol rules require it. @@ -705,16 +845,29 @@ export class Record implements RecordModel { } } + let requestOptions: ProcessDwnRequest; // Now that we've processed a potential initial write, we can process the current record state. - const requestOptions: ProcessDwnRequest = { - messageType : DwnInterface.RecordsWrite, - rawMessage : this.rawMessage, - author : this._connectedDid, - target : this._connectedDid, - dataStream : await this.data.blob(), - signAsOwner, - store, - }; + // If the record has been deleted, we need to send a delete request. Otherwise, we send a write request. + if(this.deleted) { + requestOptions = { + messageType : DwnInterface.RecordsDelete, + rawMessage : this.rawMessage, + author : this._connectedDid, + target : this._connectedDid, + signAsOwner, + store, + }; + } else { + requestOptions = { + messageType : DwnInterface.RecordsWrite, + rawMessage : this.rawMessage, + author : this._connectedDid, + target : this._connectedDid, + dataStream : await this.data.blob(), + signAsOwner, + store, + }; + } const agentResponse = await this._agent.processDwnRequest(requestOptions); const { message, reply: { status } } = agentResponse; @@ -757,7 +910,11 @@ export class Record implements RecordModel { this._agent.processDwnRequest(readRequest); try { - const { reply: { record }} = await agentResponsePromise; + const { reply: { status, record }} = await agentResponsePromise; + if (status.code !== 200) { + throw new Error(`${status.code}: ${status.detail}`); + } + const dataStream: ReadableStream | Readable = record.data; // If the data stream is a web ReadableStream, convert it to a Node.js Readable. const nodeReadable = Stream.isReadableStream(dataStream) ? diff --git a/packages/api/tests/record.spec.ts b/packages/api/tests/record.spec.ts index a2b758056..7ca554207 100644 --- a/packages/api/tests/record.spec.ts +++ b/packages/api/tests/record.spec.ts @@ -1,6 +1,7 @@ import type { BearerDid } from '@web5/dids'; import type { DwnMessageParams, DwnPublicKeyJwk, DwnSigner } from '@web5/agent'; +import sinon from 'sinon'; import { expect } from 'chai'; import { NodeStream } from '@web5/common'; import { utils as didUtils } from '@web5/dids'; @@ -43,6 +44,7 @@ describe('Record', () => { }); beforeEach(async () => { + sinon.restore(); await testHarness.clearStorage(); await testHarness.createAgentDid(); @@ -62,6 +64,7 @@ describe('Record', () => { }); after(async () => { + sinon.restore(); await testHarness.clearStorage(); await testHarness.closeStorage(); }); @@ -2031,8 +2034,6 @@ describe('Record', () => { expect(record.attestation).to.have.property('signatures'); // Retained RecordsWriteDescriptor properties. - expect(recordJson.interface).to.equal('Records'); - expect(recordJson.method).to.equal('Write'); expect(recordJson.protocol).to.equal(protocol); expect(recordJson.protocolPath).to.equal(protocolPath); expect(recordJson.recipient).to.equal(recipient); @@ -2048,6 +2049,96 @@ describe('Record', () => { }); }); + describe('toString()', () => { + it('should return a string representation of the record', async () => { + // create a record + const { record, status } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + dataFormat: 'text/plain' + } + }); + expect(status.code).to.equal(202); + + const recordString = record!.toString(); + expect(recordString).to.be.a('string'); + expect(recordString).to.contain(`ID: ${record.id}`); + expect(recordString).to.contain(`Deleted: ${false}`); // record is not deleted + expect(recordString).to.contain(`Created: ${record.dateCreated}`); + expect(recordString).to.contain(`Modified: ${record.dateModified}`); + + // data related properties + expect(recordString).to.contain(`Data CID: ${record.dataCid}`); + expect(recordString).to.contain(`Data Format: ${record.dataFormat}`); + expect(recordString).to.contain(`Data Size: ${record.dataSize}`); + }); + + it('should return a string representation of the record with protocol properties', async () => { + // install a protocol to use for the record + let { protocol: aliceProtocol, status: aliceStatus } = await dwnAlice.protocols.configure({ + message: { + definition: emailProtocolDefinition, + } + }); + expect(aliceStatus.code).to.equal(202); + expect(aliceProtocol).to.exist; + + // create a record + const { record, status } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + protocol : emailProtocolDefinition.protocol, + protocolPath : 'thread', + schema : emailProtocolDefinition.types.thread.schema, + dataFormat : 'text/plain' + } + }); + expect(status.code).to.equal(202); + + const recordString = record!.toString(); + expect(recordString).to.be.a('string'); + expect(recordString).to.contain(`ID: ${record.id}`); + expect(recordString).to.contain(`Context ID: ${record.contextId}`); + expect(recordString).to.contain(`Protocol: ${record.protocol}`); + expect(recordString).to.contain(`Schema: ${record.schema}`); + expect(recordString).to.contain(`Deleted: ${false}`); // record is not deleted + expect(recordString).to.contain(`Created: ${record.dateCreated}`); + expect(recordString).to.contain(`Modified: ${record.dateModified}`); + + // data related properties + expect(recordString).to.contain(`Data CID: ${record.dataCid}`); + expect(recordString).to.contain(`Data Format: ${record.dataFormat}`); + expect(recordString).to.contain(`Data Size: ${record.dataSize}`); + }); + + it('should return a string representation of the record in a deleted state', async () => { + // create a record + const { record, status } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + dataFormat: 'text/plain' + } + }); + expect(status.code).to.equal(202); + + // delete the record + const { status: deleteStatus } = await record!.delete(); + expect(deleteStatus.code).to.equal(202); + + const recordString = record!.toString(); + expect(recordString).to.be.a('string'); + expect(recordString).to.contain(`ID: ${record.id}`); + expect(recordString).to.contain(`Deleted: ${true}`); // record is deleted + expect(recordString).to.contain(`Created: ${record.dateCreated}`); + expect(recordString).to.contain(`Modified: ${record.dateModified}`); + + // data related properties + expect(recordString).to.not.contain('Data CID'); + expect(recordString).to.not.contain('Data Format'); + expect(recordString).to.not.contain('Data Size'); + }); + }); + describe('update()', () => { it('updates a local record on the local DWN', async () => { const { status, record } = await dwnAlice.records.write({ @@ -2084,6 +2175,56 @@ describe('Record', () => { expect(updatedData).to.equal('bye'); }); + it('updates a record to be unpublished from published', async () => { + // alice creates a record and sets it to published + const { status, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + schema : 'foo/bar', + dataFormat : 'text/plain', + published : true + } + }); + expect(status.code).to.equal(202); + expect(record).to.not.be.undefined; + + // send the record to alice's DWN + const sendResult = await record!.send(aliceDid.uri); + expect(sendResult.status.code).to.equal(202); + + // bob reads the record to confirm it is published + const readResult = await dwnBob.records.read({ + from : aliceDid.uri, + message : { + filter: { + recordId: record!.id + } + } + }); + expect(readResult.status.code).to.equal(200); + expect(readResult.record).to.not.be.undefined; + expect(readResult.record!.id).to.equal(record!.id); + + // alice updates the record to be unpublished + const updateResult = await record!.update({ published: false }); + expect(updateResult.status.code).to.equal(202); + + // send the updated record to alice's DWN + const sendResultAfterUpdate = await record!.send(aliceDid.uri); + expect(sendResultAfterUpdate.status.code).to.equal(202); + + // bob attempts to read the record again but it should not be authorized as it's unpublished + const readResultAfterUpdate = await dwnBob.records.read({ + from : aliceDid.uri, + message : { + filter: { + recordId: record!.id + } + } + }); + expect(readResultAfterUpdate.status.code).to.equal(401); + }); + it('updates a record locally that only written to a remote DWN', async () => { // Create a record but do not store it on the local DWN. const { status, record } = await dwnAlice.records.write({ @@ -2368,6 +2509,35 @@ describe('Record', () => { } }); + it('throws if a record status is deleted and initialWrite is not set', async () => { + // create a record but do not store it + const { status: writeStatus, record } = await dwnAlice.records.write({ + store : false, + data : 'Hello, world!', + message : { + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(writeStatus.code).to.equal(202); + + // delete the record but do not store it + const { status: deleteStatus } = await record.delete({ store: false }); + expect(deleteStatus.code).to.equal(202); + + // purposefully delete the _initialWrite property + delete record['_initialWrite']; + + // store the record + try { + await record.update({ data: 'hi' }); + expect.fail('Should have failed because the initial write is not set'); + } catch (error: any) { + expect(error.message).to.include('If initial write is not set, the current rawRecord must be a RecordsWrite message.'); + } + + }); + it('should override tags on update', async () => { // create a record with tags const { status, record } = await dwnAlice.records.write({ @@ -2457,6 +2627,453 @@ describe('Record', () => { }); }); + describe('delete()', () => { + it('deletes a local record on the local DWN', async () => { + const { status: writeStatus, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + + expect(writeStatus.code).to.equal(202); + expect(record).to.not.be.undefined; + + // confirm record exists + const { status: readStatus, record: readRecord } = await dwnAlice.records.read({ + message: { + filter: { + recordId: record.id + } + } + }); + + expect(readStatus.code).to.equal(200); + expect(readRecord).to.exist; + expect(readRecord!.id).to.equal(record.id); + + // delete the record + const { status: deleteStatus } = await record.delete(); + expect(deleteStatus.code).to.equal(202); + + // confirm record is in a deleted state + expect(record.deleted).to.be.true; + + // confirm the record has been deleted + const readResult = await dwnAlice.records.read({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(readResult.status.code).to.equal(404); + }); + + it('deletes a record on the remote DWN', async () => { + const { status: writeStatus, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + + expect(writeStatus.code).to.equal(202); + expect(record).to.not.be.undefined; + + // Write the record to Alice's remote DWN. + const { status } = await record!.send(aliceDid.uri); + expect(status.code).to.equal(202); + + // confirm the record has been written to the remote DWN + const readResult = await dwnAlice.records.read({ + from : aliceDid.uri, + message : { + filter: { + recordId: record.id + } + } + }); + expect(readResult.status.code).to.equal(200); + expect(readResult.record).to.exist; + expect(readResult.record.id).to.equal(record.id); + + // delete the record + const { status: deleteLocalStatus } = await record.delete(); + expect(deleteLocalStatus.code).to.equal(202); + + // confirm record is in a deleted state + expect(record.deleted).to.be.true; + + // send the delete request to the remote DWN + const { status: deleteSendStatus } = await record.send(aliceDid.uri); + expect(deleteSendStatus.code).to.equal(202); + + // confirm the record has been deleted + const readResultDeleted = await dwnAlice.records.read({ + from : aliceDid.uri, + message : { + filter: { + recordId: record.id + } + } + }); + expect(readResultDeleted.status.code).to.equal(404); + }); + + it('deletes a record and prunes its children on the local DWN', async () => { + // Install a protocol that supports parent-child relationships. + const { status: protocolStatus, protocol } = await dwnAlice.protocols.configure({ + message: { + definition: { + protocol : 'http://example.com/parent-child', + published : true, + types : { + foo: { + schema: 'http://example.com/foo', + }, + bar: { + schema: 'http://example.com/bar' + } + }, + structure: { + foo: { + bar: {} + } + } + } + } + }); + expect(protocolStatus.code).to.equal(202); + + // Write a parent record. + const { status: parentWriteStatus, record: parentRecord } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + protocol : protocol.definition.protocol, + protocolPath : 'foo', + schema : 'http://example.com/foo', + dataFormat : 'text/plain' + } + }); + expect(parentWriteStatus.code).to.equal(202); + expect(parentRecord).to.exist; + + // Write a child record. + const { status: child1WriteStatus, record: child1Record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + protocol : protocol.definition.protocol, + protocolPath : 'foo/bar', + schema : 'http://example.com/bar', + dataFormat : 'text/plain', + parentContextId : parentRecord.contextId + } + }); + expect(child1WriteStatus.code).to.equal(202); + expect(child1Record).to.exist; + + // Write a second child record. + const { status: child2WriteStatus, record: child2Record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + protocol : protocol.definition.protocol, + protocolPath : 'foo/bar', + schema : 'http://example.com/bar', + dataFormat : 'text/plain', + parentContextId : parentRecord.contextId + } + }); + expect(child2WriteStatus.code).to.equal(202); + expect(child2Record).to.exist; + + // query for child records to confirm it exists + const { status: childrenStatus, records: childrenRecords } = await dwnAlice.records.query({ + message: { + filter: { + protocol : protocol.definition.protocol, + protocolPath : 'foo/bar' + } + } + }); + expect(childrenStatus.code).to.equal(200); + expect(childrenRecords).to.exist; + expect(childrenRecords).to.have.lengthOf(2); + expect(childrenRecords.map(r => r.id)).to.have.members([child1Record.id, child2Record.id]); + + // Delete the parent record and its children. + const { status: deleteStatus } = await parentRecord.delete({ prune: true }); + expect(deleteStatus.code).to.equal(202); + + // query for child records to confirm it was deleted + const { status: childrenStatusAfterDelete, records: childrenRecordsAfterDelete } = await dwnAlice.records.query({ + message: { + filter: { + protocol : protocol.definition.protocol, + protocolPath : 'foo/bar' + } + } + }); + expect(childrenStatusAfterDelete.code).to.equal(200); + expect(childrenRecordsAfterDelete).to.exist; + expect(childrenRecordsAfterDelete).to.have.lengthOf(0); + }); + + it('deletes a record and prunes its children on the remote DWN', async () => { + // Install a protocol that supports parent-child relationships. + const { status: protocolStatus, protocol } = await dwnAlice.protocols.configure({ + message: { + definition: { + protocol : 'http://example.com/parent-child', + published : true, + types : { + foo: { + schema: 'http://example.com/foo', + }, + bar: { + schema: 'http://example.com/bar' + } + }, + structure: { + foo: { + bar: {} + } + } + } + } + }); + expect(protocolStatus.code).to.equal(202); + const { status: protocolSendStatus } = await protocol.send(aliceDid.uri); + expect(protocolSendStatus.code).to.equal(202); + + // Write a parent record. + const { status: parentWriteStatus, record: parentRecord } = await dwnAlice.records.write({ + store : false, + data : 'Hello, world!', + message : { + protocol : protocol.definition.protocol, + protocolPath : 'foo', + schema : 'http://example.com/foo', + dataFormat : 'text/plain' + } + }); + expect(parentWriteStatus.code).to.equal(202); + expect(parentRecord).to.exist; + const { status: parentSendStatus } = await parentRecord.send(aliceDid.uri); + expect(parentSendStatus.code).to.equal(202); + + // Write a child record. + const { status: child1WriteStatus, record: childRecord1 } = await dwnAlice.records.write({ + store : false, + data : 'Hello, world!', + message : { + protocol : protocol.definition.protocol, + protocolPath : 'foo/bar', + schema : 'http://example.com/bar', + dataFormat : 'text/plain', + parentContextId : parentRecord.contextId + } + }); + expect(child1WriteStatus.code).to.equal(202); + expect(childRecord1).to.exist; + const { status: child1SendStatus } = await childRecord1.send(aliceDid.uri); + expect(child1SendStatus.code).to.equal(202); + + // Write a second child record. + const { status: child2WriteStatus, record: childRecord2 } = await dwnAlice.records.write({ + store : false, + data : 'Hello, world!', + message : { + protocol : protocol.definition.protocol, + protocolPath : 'foo/bar', + schema : 'http://example.com/bar', + dataFormat : 'text/plain', + parentContextId : parentRecord.contextId + } + }); + expect(child2WriteStatus.code).to.equal(202); + expect(childRecord2).to.exist; + const { status: child2SendStatus } = await childRecord2.send(aliceDid.uri); + expect(child2SendStatus.code).to.equal(202); + + // query for child records to confirm it exists + const { status: childrenStatus, records: childrenRecords } = await dwnAlice.records.query({ + from : aliceDid.uri, + message : { + filter: { + protocol : protocol.definition.protocol, + protocolPath : 'foo/bar' + } + } + }); + expect(childrenStatus.code).to.equal(200); + expect(childrenRecords).to.exist; + expect(childrenRecords).to.have.lengthOf(2); + expect(childrenRecords.map(r => r.id)).to.have.members([childRecord1.id, childRecord2.id]); + + // Delete the parent record and its children. + const { status: deleteStatus } = await parentRecord.delete({ store: false, prune: true }); + expect(deleteStatus.code).to.equal(202); + const { status: parentDeleteStatus } = await parentRecord.send(aliceDid.uri); + expect(parentDeleteStatus.code).to.equal(202); + + // query for child records to confirm it was deleted + const { status: childrenStatusAfterDelete, records: childrenRecordsAfterDelete } = await dwnAlice.records.query({ + from : aliceDid.uri, + message : { + filter: { + protocol : protocol.definition.protocol, + protocolPath : 'foo/bar' + } + } + }); + expect(childrenStatusAfterDelete.code).to.equal(200); + expect(childrenRecordsAfterDelete).to.exist; + expect(childrenRecordsAfterDelete).to.have.lengthOf(0); + }); + + it('returns a 404 when the specified record does not exist', async () => { + // create a record but do not store it + const { status: createStatus, record } = await dwnAlice.records.write({ + store : false, + data : 'Hello, world!', + message : { + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + + expect(createStatus.code).to.equal(202); + expect(record).to.not.be.undefined; + + const deleteResult = await record.delete(); + expect(deleteResult.status.code).to.equal(404); + + // confirm record is not in deleted state + expect(record.deleted).to.be.false; + }); + + it('throws if a record status is deleted and initialWrite is not set', async () => { + // create a record but do not store it + const { status: writeStatus, record } = await dwnAlice.records.write({ + store : false, + data : 'Hello, world!', + message : { + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(writeStatus.code).to.equal(202); + + // delete the record but do not store it + const { status: deleteStatus } = await record.delete({ store: false }); + expect(deleteStatus.code).to.equal(202); + + // purposefully delete the _initialWrite property + delete record['_initialWrite']; + + // store the record + try { + await record.delete(); + expect.fail('Should have failed because the initial write is not set'); + } catch (error: any) { + expect(error.message).to.include('If initial write is not set, the current rawRecord must be a RecordsWrite message.'); + } + }); + + it('duplicate delete with store should return conflict', async () => { + // create a record + const { status: writeStatus, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(writeStatus.code).to.equal(202); + + // confirm record exists + const { status: readStatus, record: readRecord } = await dwnAlice.records.read({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(readStatus.code).to.equal(200); + expect(readRecord).to.exist; + expect(readRecord!.id).to.equal(record.id); + + // delete the record + const { status: deleteStatus } = await record.delete(); + expect(deleteStatus.code).to.equal(202); + expect(record.deleted).to.be.true; + + // attempt to delete the record again + const { status: deleteStatus2 } = await record.delete(); + expect(deleteStatus2.code).to.equal(409); + }); + + it('a record in a deleted state returns undefined for data related fields', async () => { + // create a record + const { status: writeStatus, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + schema : 'http://example.org/test-schema', + dataFormat : 'text/plain' + } + }); + expect(writeStatus.code).to.equal(202); + expect(record).to.exist; + + // check for data related properties + expect(record.dataFormat).to.equal('text/plain'); + expect(record.dataCid).to.not.be.undefined; + expect(record.dataSize).to.not.be.undefined; + expect(await record.data.text()).to.equal('Hello, world!'); + + // sanity: check immutable properties + const recordId = record.id; + expect(recordId).to.not.be.undefined; + const schema = record.schema; + expect(schema).to.equal('http://example.org/test-schema'); + const dateCreated = record.dateCreated; + expect(dateCreated).to.not.be.undefined; + + // sanity: check date modified + const dateModified = record.dateModified; + expect(dateModified).to.not.be.undefined; + + // delete the record + const { status: deleteStatus } = await record.delete(); + expect(deleteStatus.code).to.equal(202); + + // sanity: should be unchanged + expect(record.id).to.equal(recordId); + expect(record.dateCreated).to.equal(dateCreated); + expect(record.schema).to.equal(schema); + + // date modified should be greater than the initial date modified + expect(Date.parse(record.dateModified)).to.be.greaterThan(Date.parse(dateModified)); + + // check for undefined data related properties + expect(record.dataFormat).to.be.undefined; + expect(record.dataCid).to.be.undefined; + expect(record.dataSize).to.be.undefined; + + try { + await record.data.text(); + expect.fail('Expected an exception to be thrown'); + } catch (error:any) { + expect(error.message).to.include('Not Found'); + } + }); + + xit('signs a deleted message as owner'); + }); + describe('store()', () => { it('should store an external record if it has been imported by the dwn owner', async () => { // Scenario: Alice creates a record. @@ -2590,6 +3207,41 @@ describe('Record', () => { expect(storedRecord.id).to.equal(record!.id); expect(await storedRecord.data.text()).to.equal(updatedText); }); + + it('stores a deleted record to the local DWN along with the initial write', async () => { + // spy on the processMessage method to confirm it is called twice by the `store()` method + // once for the initial write and once for the delete + const processMessageSpy = sinon.spy(testHarness.dwn, 'processMessage'); + + // create a record + const { status: writeStatus, record } = await dwnAlice.records.write({ + store : false, + data : 'Hello, world!', + message : { + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(writeStatus.code).to.equal(202); + expect(record).to.exist; + + // delete the record without storing + const { status: deleteStatus } = await record.delete({ store: false }); + expect(deleteStatus.code).to.equal(202); + + // check that the record is in a deleted state + expect(record.deleted).to.be.true; + + // check that processMessage has not been called yet, as the records have not been stored + expect(processMessageSpy.callCount).to.equal(0); + + // store the record + const { status: storeStatus } = await record.store(); + expect(storeStatus.code).to.equal(202); + + // check that it was called once for initial write and once for the delete + expect(processMessageSpy.callCount).to.equal(2); + }); }); describe('import()', () => { @@ -2693,6 +3345,42 @@ describe('Record', () => { expect(storedRecord.id).to.equal(record.id); }); + it('imports a deleted record to the local DWN along with the initial write', async () => { + + // spy on the processMessage method to confirm it is called twice by the `import()` method + // once for the initial write and once for the delete + const processMessageSpy = sinon.spy(testHarness.dwn, 'processMessage'); + + // create a record + const { status: writeStatus, record } = await dwnAlice.records.write({ + store : false, + data : 'Hello, world!', + message : { + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(writeStatus.code).to.equal(202); + expect(record).to.exist; + + // delete the record without storing + const { status: deleteStatus } = await record.delete({ store: false }); + expect(deleteStatus.code).to.equal(202); + + // check that the record is in a deleted state + expect(record.deleted).to.be.true; + + // dwn processMessage should not have been called yet as it hasn't been stored + expect(processMessageSpy.callCount).to.equal(0); + + // store the record + const { status: importedStatus } = await record.import(); + expect(importedStatus.code).to.equal(202); + + // check that it was called once for initial write and once for the delete + expect(processMessageSpy.callCount).to.equal(2); + }); + describe('store: false', () => { it('should import an external record without storing it', async () => { // Scenario: Alice creates a record. @@ -2891,5 +3579,26 @@ describe('Record', () => { value: record.datePublished, }); }); + + it('should return undefined if record is in a deleted state', async () => { + // create a record + const { status: writeStatus, record } = await dwnAlice.records.write({ + store : false, + data : 'Hello, world!', + message : { + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(writeStatus.code).to.equal(202); + + // delete the record + const { status: deleteStatus } = await record.delete({ store: false }); + expect(deleteStatus.code).to.equal(202); + + // get a pagination cursor + const paginationCursor = await record.paginationCursor(DwnDateSort.CreatedAscending); + expect(paginationCursor).to.be.undefined; + }); }); }); \ No newline at end of file