diff --git a/build/compile-validators.js b/build/compile-validators.js index 6bf5c1ed3..6b134b4c5 100644 --- a/build/compile-validators.js +++ b/build/compile-validators.js @@ -17,78 +17,53 @@ import Ajv from 'ajv/dist/2020.js'; import mkdirp from 'mkdirp'; import standaloneCode from 'ajv/dist/standalone/index.js'; -import Authorization from '../json-schemas/authorization.json' assert { type: 'json' }; -import AuthorizationDelegatedGrant from '../json-schemas/authorization-delegated-grant.json' assert { type: 'json' }; -import AuthorizationOwner from '../json-schemas/authorization-owner.json' assert { type: 'json' }; -import Definitions from '../json-schemas/definitions.json' assert { type: 'json' }; -import GeneralJwk from '../json-schemas/jwk/general-jwk.json' assert { type: 'json' }; -import GeneralJws from '../json-schemas/general-jws.json' assert { type: 'json' }; -import GenericSignaturePayload from '../json-schemas/signature-payloads/generic-signature-payload.json' assert { type: 'json' }; -import JwkVerificationMethod from '../json-schemas/jwk-verification-method.json' assert { type: 'json' }; -import MessagesFilter from '../json-schemas/interface-methods/messages-filter.json' assert { type: 'json' }; -import MessagesQuery from '../json-schemas/interface-methods/messages-query.json' assert { type: 'json' }; -import MessagesRead from '../json-schemas/interface-methods/messages-read.json' assert { type: 'json' }; -import MessagesSubscribe from '../json-schemas/interface-methods/messages-subscribe.json' assert { type: 'json' }; -import NumberRangeFilter from '../json-schemas/interface-methods/number-range-filter.json' assert { type: 'json' }; -import PaginationCursor from '../json-schemas/interface-methods/pagination-cursor.json' assert { type: 'json' }; -import PermissionGrantData from '../json-schemas/permissions/permission-grant-data.json' assert { type: 'json' }; -import PermissionRequestData from '../json-schemas/permissions/permission-request-data.json' assert { type: 'json' }; -import PermissionRevocationData from '../json-schemas/permissions/permission-revocation-data.json' assert { type: 'json' }; -import PermissionsDefinitions from '../json-schemas/permissions/permissions-definitions.json' assert { type: 'json' }; -import PermissionsScopes from '../json-schemas/permissions/scopes.json' assert { type: 'json' }; -import ProtocolDefinition from '../json-schemas/interface-methods/protocol-definition.json' assert { type: 'json' }; -import ProtocolRuleSet from '../json-schemas/interface-methods/protocol-rule-set.json' assert { type: 'json' }; -import ProtocolsConfigure from '../json-schemas/interface-methods/protocols-configure.json' assert { type: 'json' }; -import ProtocolsQuery from '../json-schemas/interface-methods/protocols-query.json' assert { type: 'json' }; -import PublicJwk from '../json-schemas/jwk/public-jwk.json' assert { type: 'json' }; -import RecordsDelete from '../json-schemas/interface-methods/records-delete.json' assert { type: 'json' }; -import RecordsFilter from '../json-schemas/interface-methods/records-filter.json' assert { type: 'json' }; -import RecordsQuery from '../json-schemas/interface-methods/records-query.json' assert { type: 'json' }; -import RecordsRead from '../json-schemas/interface-methods/records-read.json' assert { type: 'json' }; -import RecordsSubscribe from '../json-schemas/interface-methods/records-subscribe.json' assert { type: 'json' }; -import RecordsWrite from '../json-schemas/interface-methods/records-write.json' assert { type: 'json' }; -import RecordsWriteDataEncoded from '../json-schemas/interface-methods/records-write-data-encoded.json' assert { type: 'json' }; -import RecordsWriteSignaturePayload from '../json-schemas/signature-payloads/records-write-signature-payload.json' assert { type: 'json' }; -import RecordsWriteUnidentified from '../json-schemas/interface-methods/records-write-unidentified.json' assert { type: 'json' }; -import StringRangeFilter from '../json-schemas/interface-methods/string-range-filter.json' assert { type: 'json' }; +const loadJson = (relativePath) => { + // Node 22 replaces JSON import assertions with `with` blocks; load via fs to keep compatibility. + const fileUrl = new URL(relativePath, import.meta.url); + return JSON.parse(fs.readFileSync(fileUrl, 'utf-8')); +}; -const schemas = { - Authorization, - AuthorizationDelegatedGrant, - AuthorizationOwner, - RecordsDelete, - RecordsQuery, - RecordsSubscribe, - RecordsWrite, - RecordsWriteDataEncoded, - RecordsWriteUnidentified, - Definitions, - GeneralJwk, - GeneralJws, - JwkVerificationMethod, - MessagesFilter, - MessagesQuery, - MessagesRead, - MessagesSubscribe, - NumberRangeFilter, - PaginationCursor, - PermissionGrantData, - PermissionRequestData, - PermissionRevocationData, - PermissionsDefinitions, - PermissionsScopes, - ProtocolDefinition, - ProtocolRuleSet, - ProtocolsConfigure, - ProtocolsQuery, - RecordsRead, - RecordsFilter, - PublicJwk, - GenericSignaturePayload, - RecordsWriteSignaturePayload, - StringRangeFilter +const schemaPaths = { + Authorization: '../json-schemas/authorization.json', + AuthorizationDelegatedGrant: '../json-schemas/authorization-delegated-grant.json', + AuthorizationOwner: '../json-schemas/authorization-owner.json', + RecordsDelete: '../json-schemas/interface-methods/records-delete.json', + RecordsQuery: '../json-schemas/interface-methods/records-query.json', + RecordsSubscribe: '../json-schemas/interface-methods/records-subscribe.json', + RecordsWrite: '../json-schemas/interface-methods/records-write.json', + RecordsWriteDataEncoded: '../json-schemas/interface-methods/records-write-data-encoded.json', + RecordsWriteUnidentified: '../json-schemas/interface-methods/records-write-unidentified.json', + Definitions: '../json-schemas/definitions.json', + GeneralJwk: '../json-schemas/jwk/general-jwk.json', + GeneralJws: '../json-schemas/general-jws.json', + JwkVerificationMethod: '../json-schemas/jwk-verification-method.json', + MessagesFilter: '../json-schemas/interface-methods/messages-filter.json', + MessagesQuery: '../json-schemas/interface-methods/messages-query.json', + MessagesRead: '../json-schemas/interface-methods/messages-read.json', + MessagesSubscribe: '../json-schemas/interface-methods/messages-subscribe.json', + NumberRangeFilter: '../json-schemas/interface-methods/number-range-filter.json', + PaginationCursor: '../json-schemas/interface-methods/pagination-cursor.json', + PermissionGrantData: '../json-schemas/permissions/permission-grant-data.json', + PermissionRequestData: '../json-schemas/permissions/permission-request-data.json', + PermissionRevocationData: '../json-schemas/permissions/permission-revocation-data.json', + PermissionsDefinitions: '../json-schemas/permissions/permissions-definitions.json', + PermissionsScopes: '../json-schemas/permissions/scopes.json', + ProtocolDefinition: '../json-schemas/interface-methods/protocol-definition.json', + ProtocolRuleSet: '../json-schemas/interface-methods/protocol-rule-set.json', + ProtocolsConfigure: '../json-schemas/interface-methods/protocols-configure.json', + ProtocolsQuery: '../json-schemas/interface-methods/protocols-query.json', + RecordsRead: '../json-schemas/interface-methods/records-read.json', + RecordsFilter: '../json-schemas/interface-methods/records-filter.json', + PublicJwk: '../json-schemas/jwk/public-jwk.json', + GenericSignaturePayload: '../json-schemas/signature-payloads/generic-signature-payload.json', + RecordsWriteSignaturePayload: '../json-schemas/signature-payloads/records-write-signature-payload.json', + StringRangeFilter: '../json-schemas/interface-methods/string-range-filter.json' }; +const schemas = Object.fromEntries( + Object.entries(schemaPaths).map(([schemaName, relativePath]) => [schemaName, loadJson(relativePath)]) +); + const ajv = new Ajv({ code: { source: true, esm: true }, allowUnionTypes: true }); for (const schemaName in schemas) { diff --git a/src/store/index-level.ts b/src/store/index-level.ts index 7a33bfb15..1ae872fc7 100644 --- a/src/store/index-level.ts +++ b/src/store/index-level.ts @@ -258,7 +258,11 @@ export class IndexLevel { queryOptions: QueryOptions, options?: IndexLevelOptions ): Promise { - const { cursor: queryCursor , limit } = queryOptions; + const { cursor: queryCursor , limit, sortProperty } = queryOptions; + + if (queryCursor !== undefined) { + await this.assertCursorValueMatchesSortProperty(tenant, queryCursor, sortProperty); + } // if there is a cursor we fetch the starting key given the sort property, otherwise we start from the beginning of the index. const startKey = queryCursor ? this.createStartingKeyFromCursor(queryCursor) : ''; @@ -318,6 +322,41 @@ export class IndexLevel { return IndexLevel.keySegmentJoin(IndexLevel.encodeValue(value), messageCid); } + private async assertCursorValueMatchesSortProperty(tenant: string, cursor: PaginationCursor, sortProperty: string): Promise { + const indexes = await this.getIndexes(tenant, cursor.messageCid); + if (indexes === undefined) { + // if the messageCid cannot be resolved we cannot validate the expected type, + // preserve existing behavior and allow the query to proceed. + return; + } + + const sortValue = indexes[sortProperty]; + if (sortValue === undefined) { + throw new DwnError( + DwnErrorCode.IndexInvalidCursorSortProperty, + `the sort property '${sortProperty}' is not defined within the given item.` + ); + } + + const sortValueIsArray = Array.isArray(sortValue); + const sortValueType = sortValueIsArray ? 'array' : typeof sortValue; + + if (sortValueType === 'boolean' || sortValueIsArray) { + throw new DwnError( + DwnErrorCode.IndexInvalidCursorValueType, + `only string or number values are supported for cursors, a(n) ${sortValueType} was defined for sort property '${sortProperty}'.` + ); + } + + const cursorValueType = typeof cursor.value; + if (cursorValueType !== sortValueType) { + throw new DwnError( + DwnErrorCode.IndexInvalidCursorValueType, + `cursor value type '${cursorValueType}' does not match expected '${sortValueType}' for sort property '${sortProperty}'.` + ); + } + } + /** * Returns a PaginationCursor using the last item of a given array of IndexedItems. * If the given array is empty, undefined is returned. @@ -370,6 +409,10 @@ export class IndexLevel { ): Promise { const { sortProperty, sortDirection = SortDirection.Ascending, cursor: queryCursor, limit } = queryOptions; + if (queryCursor !== undefined) { + await this.assertCursorValueMatchesSortProperty(tenant, queryCursor, sortProperty); + } + // we get the cursor start key here so that we match the failing behavior of `queryWithIteratorPaging` const cursorStartingKey = queryCursor ? this.createStartingKeyFromCursor(queryCursor) : undefined; diff --git a/tests/store/index-level.spec.ts b/tests/store/index-level.spec.ts index 630ac125d..9e1a45844 100644 --- a/tests/store/index-level.spec.ts +++ b/tests/store/index-level.spec.ts @@ -261,6 +261,17 @@ describe('IndexLevel', () => { const noResults = await testIndex.queryWithIteratorPaging(tenant, filters, { sortProperty: 'val', cursor: cursorE }); expect(noResults.length).to.eql(0); }); + + it('throws when cursor value type does not match sort property type', async () => { + await testIndex.put(tenant, 'alpha', { schema: 'schema', val: 1 }); + await testIndex.put(tenant, 'beta', { schema: 'schema', val: 2 }); + + const filters = [{ schema: 'schema' }]; + const mismatchedCursor = { messageCid: 'alpha', value: '1' }; + + const queryPromise = testIndex.queryWithIteratorPaging(tenant, filters, { sortProperty: 'val', cursor: mismatchedCursor }); + await expect(queryPromise).to.eventually.be.rejectedWith(DwnErrorCode.IndexInvalidCursorValueType); + }); }); describe('array values', () => { @@ -421,6 +432,17 @@ describe('IndexLevel', () => { expect(noResults.length).to.eql(0); }); + it('throws when in-memory cursor value type does not match sort property type', async () => { + await testIndex.put(tenant, 'alpha', { schema: 'schema', val: 1 }); + await testIndex.put(tenant, 'beta', { schema: 'schema', val: 2 }); + + const filters = [{ schema: 'schema' }]; + const mismatchedCursor = { messageCid: 'alpha', value: '1' }; + + const queryPromise = testIndex.queryWithInMemoryPaging(tenant, filters, { sortProperty: 'val', cursor: mismatchedCursor }); + await expect(queryPromise).to.eventually.be.rejectedWith(DwnErrorCode.IndexInvalidCursorValueType); + }); + it('supports range queries', async () => { const id = uuid(); const doc1 = { @@ -1477,4 +1499,4 @@ describe('IndexLevel', () => { expect(IndexLevel.isFilterConcise({ protocol: 'protocol', protocolPath: 'path/to' }, queryOptionsWithoutCursor)).to.equal(true); }); }); -}); \ No newline at end of file +});