Skip to content

Commit 15a253b

Browse files
authored
#207 - implemented RecordsDelete
1 parent dd56276 commit 15a253b

File tree

22 files changed

+630
-113
lines changed

22 files changed

+630
-113
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Decentralized Web Node (DWN) SDK
44

55
Code Coverage
6-
![Statements](https://img.shields.io/badge/statements-93.62%25-brightgreen.svg?style=flat) ![Branches](https://img.shields.io/badge/branches-91.57%25-brightgreen.svg?style=flat) ![Functions](https://img.shields.io/badge/functions-90.38%25-brightgreen.svg?style=flat) ![Lines](https://img.shields.io/badge/lines-93.62%25-brightgreen.svg?style=flat)
6+
![Statements](https://img.shields.io/badge/statements-94%25-brightgreen.svg?style=flat) ![Branches](https://img.shields.io/badge/branches-92.19%25-brightgreen.svg?style=flat) ![Functions](https://img.shields.io/badge/functions-90.85%25-brightgreen.svg?style=flat) ![Lines](https://img.shields.io/badge/lines-94%25-brightgreen.svg?style=flat)
77

88
## Introduction
99

build/compile-validators.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ import ProtocolRuleSet from '../json-schemas/protocol-rule-set.json' assert { ty
3030
import ProtocolsConfigure from '../json-schemas/protocols/protocols-configure.json' assert { type: 'json' };
3131
import ProtocolsQuery from '../json-schemas/protocols/protocols-query.json' assert { type: 'json' };
3232
import PublicJwk from '../json-schemas/jwk/public-jwk.json' assert { type: 'json' };
33+
import RecordsDelete from '../json-schemas/records/records-delete.json' assert { type: 'json' };
3334
import RecordsQuery from '../json-schemas/records/records-query.json' assert { type: 'json' };
3435
import RecordsWrite from '../json-schemas/records/records-write.json' assert { type: 'json' };
3536

3637
const schemas = {
38+
RecordsDelete,
3739
RecordsQuery,
3840
RecordsWrite,
3941
Definitions,
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "https://identity.foundation/dwn/json-schemas/records-delete.json",
4+
"type": "object",
5+
"additionalProperties": false,
6+
"required": [
7+
"authorization",
8+
"descriptor"
9+
],
10+
"properties": {
11+
"authorization": {
12+
"$ref": "https://identity.foundation/dwn/json-schemas/general-jws.json"
13+
},
14+
"descriptor": {
15+
"type": "object",
16+
"additionalProperties": false,
17+
"required": [
18+
"interface",
19+
"method",
20+
"dateModified",
21+
"recordId"
22+
],
23+
"properties": {
24+
"interface": {
25+
"enum": [
26+
"Records"
27+
],
28+
"type": "string"
29+
},
30+
"method": {
31+
"enum": [
32+
"Delete"
33+
],
34+
"type": "string"
35+
},
36+
"dateModified": {
37+
"type": "string"
38+
},
39+
"recordId": {
40+
"type": "string"
41+
}
42+
}
43+
}
44+
}
45+
}

src/core/message.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { SignatureInput } from '../jose/jws/general/types.js';
2-
import type { BaseDecodedAuthorizationPayload, BaseMessage, Descriptor } from './types.js';
2+
import type { BaseDecodedAuthorizationPayload, BaseMessage, Descriptor, TimestampedMessage } from './types.js';
33

44
import { CID } from 'multiformats/cid';
55
import { GeneralJws } from '../jose/jws/general/types.js';
@@ -19,9 +19,11 @@ export enum DwnInterfaceName {
1919

2020
export enum DwnMethodName {
2121
Configure = 'Configure',
22+
Grant = 'Grant',
2223
Query = 'Query',
2324
Request = 'Request',
24-
Write = 'Write'
25+
Write = 'Write',
26+
Delete = 'Delete'
2527
}
2628

2729
export abstract class Message {
@@ -136,4 +138,53 @@ export abstract class Message {
136138

137139
return signer.getJws();
138140
}
141+
142+
143+
/**
144+
* @returns newest message in the array. `undefined` if given array is empty.
145+
*/
146+
public static async getNewestMessage(messages: TimestampedMessage[]): Promise<TimestampedMessage | undefined> {
147+
let currentNewestMessage: TimestampedMessage | undefined = undefined;
148+
for (const message of messages) {
149+
if (currentNewestMessage === undefined || await Message.isNewer(message, currentNewestMessage)) {
150+
currentNewestMessage = message;
151+
}
152+
}
153+
154+
return currentNewestMessage;
155+
}
156+
157+
/**
158+
* Checks if first message is newer than second message.
159+
* @returns `true` if `a` is newer than `b`; `false` otherwise
160+
*/
161+
public static async isNewer(a: TimestampedMessage, b: TimestampedMessage): Promise<boolean> {
162+
const aIsNewer = (await Message.compareModifiedTime(a, b) > 0);
163+
return aIsNewer;
164+
}
165+
166+
/**
167+
* Checks if first message is older than second message.
168+
* @returns `true` if `a` is older than `b`; `false` otherwise
169+
*/
170+
public static async isOlder(a: TimestampedMessage, b: TimestampedMessage): Promise<boolean> {
171+
const aIsNewer = (await Message.compareModifiedTime(a, b) < 0);
172+
return aIsNewer;
173+
}
174+
175+
/**
176+
* Compares the `dateModified` of the given messages with a fallback to message CID according to the spec.
177+
* @returns 1 if `a` is larger/newer than `b`; -1 if `a` is smaller/older than `b`; 0 otherwise (same age)
178+
*/
179+
public static async compareModifiedTime(a: TimestampedMessage, b: TimestampedMessage): Promise<number> {
180+
if (a.descriptor.dateModified > b.descriptor.dateModified) {
181+
return 1;
182+
} else if (a.descriptor.dateModified < b.descriptor.dateModified) {
183+
return -1;
184+
}
185+
186+
// else `dateModified` is the same between a and b
187+
// compare the `dataCid` instead, the < and > operators compare strings in lexicographical order
188+
return Message.compareCid(a, b);
189+
}
139190
}

src/core/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ export type Descriptor = {
2323
method: string;
2424
};
2525

26+
/**
27+
* Messages that have `dateModified` in their `descriptor` property.
28+
*/
29+
export type TimestampedMessage = BaseMessage & {
30+
descriptor: {
31+
dateModified: string;
32+
}
33+
};
34+
2635
/**
2736
* Message that references `dataCid`.
2837
*/

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
// - https://stackoverflow.com/questions/44979976/typescript-compiler-is-forgetting-to-add-file-extensions-to-es6-module-imports
1010
// - https://github.com/microsoft/TypeScript/issues/40878
1111
//
12-
export type { RecordsQueryMessage, RecordsWriteMessage } from './interfaces/records/types.js';
12+
export type { RecordsDeleteMessage, RecordsQueryMessage, RecordsWriteMessage } from './interfaces/records/types.js';
1313
export type { Config } from './dwn.js';
1414
export type { HooksWriteMessage } from './interfaces/hooks/types.js';
1515
export type { ProtocolDefinition, ProtocolRuleSet, ProtocolsConfigureMessage, ProtocolsQueryMessage } from './interfaces/protocols/types.js';
1616
export type { DwnServiceEndpoint, ServiceEndpoint, DidDocument, DidResolutionResult, DidResolutionMetadata, DidDocumentMetadata, VerificationMethod } from './did/did-resolver.js';
17+
export { RecordsDelete, RecordsDeleteOptions } from './interfaces/records/messages/records-delete.js';
1718
export { RecordsQuery, RecordsQueryOptions } from './interfaces/records/messages/records-query.js';
1819
export { RecordsWrite, RecordsWriteOptions, CreateFromOptions } from './interfaces/records/messages/records-write.js';
1920
export { DateSort } from './interfaces/records/messages/records-query.js';

src/interfaces/permissions/messages/permissions-grant.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import type { PermissionsGrantDescriptor, PermissionsGrantMessage } from '../typ
66
import { CID } from 'multiformats/cid';
77
import { generateCid } from '../../../utils/cid';
88
import { getCurrentTimeInHighPrecision } from '../../../utils/time';
9-
import { Message } from '../../../core/message';
109
import { v4 as uuidv4 } from 'uuid';
10+
1111
import { DEFAULT_CONDITIONS, PermissionsRequest } from './permissions-request';
12+
import { DwnInterfaceName, DwnMethodName, Message } from '../../../core/message';
1213

1314
type PermissionsGrantOptions = AuthCreateOptions & {
1415
dateCreated?: string;
@@ -34,21 +35,22 @@ export class PermissionsGrant extends Message {
3435
const mergedConditions = { ...DEFAULT_CONDITIONS, ...providedConditions };
3536

3637
const descriptor: PermissionsGrantDescriptor = {
38+
interface : DwnInterfaceName.Permissions,
39+
method : DwnMethodName.Grant,
3740
dateCreated : options.dateCreated ?? getCurrentTimeInHighPrecision(),
3841
conditions : mergedConditions,
3942
description : options.description,
4043
grantedTo : options.grantedTo,
4144
grantedBy : options.grantedBy,
42-
method : 'PermissionsGrant',
4345
objectId : options.objectId ? options.objectId : uuidv4(),
4446
scope : options.scope,
4547
};
4648

47-
Message.validateJsonSchema({ descriptor, authorization: { } });
48-
4949
const authorization = await Message.signAsAuthorization(descriptor, options.signatureInput);
5050
const message: PermissionsGrantMessage = { descriptor, authorization };
5151

52+
Message.validateJsonSchema(message);
53+
5254
return new PermissionsGrant(message);
5355
}
5456

src/interfaces/permissions/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,14 @@ export type PermissionsRequestMessage = BaseMessage & {
4949
};
5050

5151
export type PermissionsGrantDescriptor = {
52+
interface : DwnInterfaceName.Permissions
53+
method: DwnMethodName.Grant;
5254
dateCreated: string;
5355
conditions: PermissionConditions;
5456
delegatedFrom?: string;
5557
description: string;
5658
grantedTo: string;
5759
grantedBy: string;
58-
method: 'PermissionsGrant';
5960
objectId: string;
6061
permissionsRequestId?: string;
6162
scope: PermissionScope;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { MethodHandler } from '../../types.js';
2+
import type { RecordsDeleteMessage } from '../types.js';
3+
4+
import { authenticate } from '../../../core/auth.js';
5+
import { deleteAllOlderMessagesButKeepInitialWrite } from '../records-interface.js';
6+
import { DwnInterfaceName } from '../../../core/message.js';
7+
import { MessageReply } from '../../../core/message-reply.js';
8+
import { RecordsDelete } from '../messages/records-delete.js';
9+
import { RecordsWrite } from '../messages/records-write.js';
10+
import { TimestampedMessage } from '../../../core/types.js';
11+
12+
export const handleRecordsDelete: MethodHandler = async (
13+
tenant,
14+
message,
15+
messageStore,
16+
didResolver
17+
): Promise<MessageReply> => {
18+
const incomingMessage = message as RecordsDeleteMessage;
19+
20+
let recordsDelete: RecordsDelete;
21+
try {
22+
recordsDelete = await RecordsDelete.parse(incomingMessage);
23+
} catch (e) {
24+
return new MessageReply({
25+
status: { code: 400, detail: e.message }
26+
});
27+
}
28+
29+
// authentication & authorization
30+
try {
31+
await authenticate(message.authorization, didResolver);
32+
await recordsDelete.authorize(tenant);
33+
} catch (e) {
34+
return new MessageReply({
35+
status: { code: 401, detail: e.message }
36+
});
37+
}
38+
39+
// get existing records matching the `recordId`
40+
const query = {
41+
tenant,
42+
interface : DwnInterfaceName.Records,
43+
recordId : incomingMessage.descriptor.recordId
44+
};
45+
const existingMessages = await messageStore.query(query) as TimestampedMessage[];
46+
47+
// find which message is the newest, and if the incoming message is the newest
48+
const newestExistingMessage = await RecordsWrite.getNewestMessage(existingMessages);
49+
let incomingMessageIsNewest = false;
50+
let newestMessage;
51+
// if incoming message is newest
52+
if (newestExistingMessage === undefined || await RecordsWrite.isNewer(incomingMessage, newestExistingMessage)) {
53+
incomingMessageIsNewest = true;
54+
newestMessage = incomingMessage;
55+
} else { // existing message is the same age or newer than the incoming message
56+
newestMessage = newestExistingMessage;
57+
}
58+
59+
// write the incoming message to DB if incoming message is newest
60+
let messageReply: MessageReply;
61+
if (incomingMessageIsNewest) {
62+
const indexes = await constructIndexes(tenant, recordsDelete);
63+
64+
await messageStore.put(incomingMessage, indexes);
65+
66+
messageReply = new MessageReply({
67+
status: { code: 202, detail: 'Accepted' }
68+
});
69+
} else {
70+
messageReply = new MessageReply({
71+
status: { code: 409, detail: 'Conflict' }
72+
});
73+
}
74+
75+
// delete all existing messages that are not newest, except for the initial write
76+
await deleteAllOlderMessagesButKeepInitialWrite(tenant, existingMessages, newestMessage, messageStore);
77+
78+
return messageReply;
79+
};
80+
81+
export async function constructIndexes(tenant: string, recordsDelete: RecordsDelete): Promise<{ [key: string]: string }> {
82+
const message = recordsDelete.message;
83+
const descriptor = { ...message.descriptor };
84+
85+
// NOTE: the "trick" not may not be apparent on how a query is able to omit deleted records:
86+
// we intentionally not add index for `isLatestBaseState` at all, this means that upon a successful delete,
87+
// no messages with the record ID will match any query because queries by design filter by `isLatestBaseState = true`,
88+
// `isLatestBaseState` for the initial delete would have been toggled to `false`
89+
const indexes: { [key: string]: any } = {
90+
tenant,
91+
// isLatestBaseState : "true", // intentionally showing that this index is omitted
92+
author: recordsDelete.author,
93+
...descriptor
94+
};
95+
96+
return indexes;
97+
}

0 commit comments

Comments
 (0)